diff --git a/.gitignore b/.gitignore index 9e857c8..aea4f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build/ world.json world.json.tmp +server_mods/ diff --git a/MODS.md b/MODS.md index 1373860..9aa3fc9 100644 --- a/MODS.md +++ b/MODS.md @@ -79,7 +79,8 @@ refers to exists (and that it depends on the mods those come from), and typechec `common/mod_api/`, which scripts import as `bworld/server`, `bworld/client` and `bworld/worldgen`. Folders in `mods/` starting with `_` or `.` are ignored. -The game can't load mods yet; see the [Implementation plan](#implementation-plan). +`deno task build` builds every mod in `mods/` and fails if any has errors; `deno task server` then loads them. What +works so far is listed in the [Implementation plan](#implementation-plan). ## Mod layout @@ -1039,8 +1040,8 @@ Worlds saved before the move must load afterwards with nothing changed: Each phase ends with every test passing and the game playable. -**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which so far -checks `mods/bworld` against the game and the mod tools. +**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which covers +loading mods (the base game and the template), their scripts and worldgen, saves, and the mod tools. - Move the test scripts used while building steps 1–3 into the repo as `deno test` files under `tests/`: server logic (breaking, placing, crafting, chest, furnace, saves), client and server terrain agreement, click prediction, and the @@ -1051,13 +1052,13 @@ checks `mods/bworld` against the game and the mod tools. - Save a world from the current code with chests, a running furnace and some player inventories as `tests/fixtures/world_v2.json`, with a test that loads it and checks everything is where it was. -**Phase 1: data** (after steps 4 and 5). +**Phase 1: data** (after steps 4 and 5). _Done._ - Create `mods/bworld` with its manifest and credits, and move the 62 textures there. Rename the breaking cracks to `engine:break_0`–`8` and the fallback to `engine:missing`. - Generate the block, item and recipe JSON with a script that reads the current registries, instead of writing it by - hand. Hand-copying 18 blocks' fields is how typos get in. Done: `deno task export-bworld` writes them, and - `tests/bworld_mod_test.ts` fails if they drift from the game. + hand. Hand-copying 18 blocks' fields is how typos get in. They were generated this way, checked equal to the + TypeScript definitions, and those were then deleted. - The loader registers the recipes and `server/game/crafting.ts` matches against them. Behaviors stay in `server/game/blocks.ts`, still keyed by block id, for now. - Delete `common/blocks/` and `common/items/`. @@ -1106,7 +1107,8 @@ checks `mods/bworld` against the game and the mod tools. ## Implementation plan -Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working: +Steps 1–4 are done and 5, 6 and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the +game working: 1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package) and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server @@ -1119,16 +1121,22 @@ Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps reach a server, and show a connection error instead. 4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries, build the combined atlas (textures are currently all named `bworld:`), bundle scripts per side, and write - hashed output to `build/mods/` and `server_mods/`. + hashed output to `build/mods/` and `server_mods/`. _Done._ 5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before - joining. Add the confirm screen for cross-origin servers and CORS headers on the server. + joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Partly done:_ `welcome` + lists the mods and clients download and run them before creating the world, but the server doesn't wait for `ready` + (its messages just queue up meanwhile), hashes aren't checked, and there's no confirm screen or CORS yet. 6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and - `FUEL_VALUES` into the recipe registry. + `FUEL_VALUES` into the recipe registry. _Mostly done_ (`server/game/mod_runtime.ts`). Not yet: `on_click` (clients + don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state` + (block states aren't synced or saved), and `Container.on_change`. `ctx.containers`, `ctx.ui` and `ctx.net` throw + until steps 7 and 8, and so do the client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`. 7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics` API (built on the existing renderer and debug UI widgets) and the HUD. 8. **Mod channels** and keybinds. 9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing - before generating anything. + before generating anything. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base + terrain out of the engine. Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4 plus data in step 5) before scripts exist. diff --git a/build.ts b/build.ts index 82b4796..ff9a6fa 100644 --- a/build.ts +++ b/build.ts @@ -1,11 +1,26 @@ import { copy } from "@std/fs"; import { createCanvas, loadImage } from "@gfx/canvas-wasm"; +import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts"; +import type { ModData, ModListing } from "./common/mod_loader.ts"; -const BUILD_FOLDER = "build"; +// all overridable so tests can build somewhere else +const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build"; +const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods"; +// server scripts go here instead of the served build folder, players never get them +const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods"; -function clear_folder() { - Deno.removeSync(BUILD_FOLDER, { recursive: true }); - Deno.mkdirSync(BUILD_FOLDER); +// what the server reads at startup, see server/main.ts +export interface ServerModIndex { + mods: { listing: ModListing; server?: string }[]; +} + +function clear_folder(folder: string) { + try { + Deno.removeSync(folder, { recursive: true }); + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + Deno.mkdirSync(folder, { recursive: true }); } async function build_fonts() { @@ -59,14 +74,22 @@ function calculate_atlas_size(count: number) { }; } -async function build_atlas_from_folder(folder: string) { - let sprite_count = 0; - for (const entry of Deno.readDirSync(folder)) { - console.assert(entry.isFile && entry.name.endsWith(".png")); - sprite_count += 1; +// one atlas with the engine's textures (engine:) and every mod's (:) +async function build_atlas(mods: LoadedMod[]) { + const textures = new Map(); + for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) { + if (entry.isFile && entry.name.endsWith(".png")) { + textures.set(`engine:${entry.name.replace(".png", "")}`, `${ENGINE_TEXTURE_DIR}/${entry.name}`); + } + } + for (const mod of mods) { + for (const [id, path] of mod.texture_files) { + textures.set(id, path); + } } - const atlas = calculate_atlas_size(sprite_count); + // + 1 for the missing texture + const atlas = calculate_atlas_size(textures.size + 1); const canvas = createCanvas(atlas.size, atlas.size); const ctx = canvas.getContext("2d"); @@ -80,40 +103,119 @@ async function build_atlas_from_folder(folder: string) { ctx.fillStyle = "black"; ctx.fillRect(8, 0, 8, 8); ctx.fillRect(0, 8, 8, 8); + atlas_info["engine:missing"] = { x: 0, y: 0 }; let index = 1; - for (const entry of Deno.readDirSync(folder)) { - const sprite = await loadImage(`${folder}/${entry.name}`); + for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) { + const sprite = await loadImage(path); const row = Math.floor(index / atlas.sprites_per_side); const column = index % atlas.sprites_per_side; ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE); index += 1; - - const id = `bworld:${entry.name.replace(".png", "")}`; atlas_info[id] = { x: column, y: row }; } - Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer()); - Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info)); + Deno.writeFileSync(`${BUILD_FOLDER}/assets/sprites/textures.png`, canvas.toBuffer()); + Deno.writeTextFileSync(`${BUILD_FOLDER}/assets/sprites/textures.json`, JSON.stringify(atlas_info)); } -async function build_sprites() { +async function build_sprites(mods: LoadedMod[]) { for (const entry of Deno.readDirSync("assets/sprites")) { if (entry.name.endsWith(".png")) { await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`); - } else if (entry.isDirectory) { - await build_atlas_from_folder(`assets/sprites/${entry.name}`); } } + await build_atlas(mods); } -async function build_assets() { +async function build_assets(mods: LoadedMod[]) { Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true }); await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`); - build_fonts(); + await build_fonts(); Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true }); - await build_sprites(); + await build_sprites(mods); +} + +// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build, +// the game never runs with some mods missing +function load_mods(): LoadedMod[] { + const mods = load_and_check(MODS_FOLDER); + const problems = mods.flatMap((mod) => mod.report.errors.map((error) => ` ${mod.id}: ${error}`)); + if (problems.length > 0) { + throw new Error(`Mods have errors (deno task check-mods for details):\n${problems.join("\n")}`); + } + return load_order(mods); +} + +async function bundle_script(path: string, platform: "browser" | "deno"): Promise { + const result = await Deno.bundle({ entrypoints: [path], platform, write: false, minify: false }); + if (!result.success || !result.outputFiles?.length) { + throw new Error(`Couldn't bundle ${path}:\n${result.errors.map((e) => e.text).join("\n")}`); + } + return result.outputFiles[0].text(); +} + +async function short_hash(parts: string[]) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0"))); + return [...new Uint8Array(digest)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// build/mods/// gets what players download, server_mods/ the server scripts +async function build_mods(mods: LoadedMod[]) { + clear_folder(SERVER_MODS_FOLDER); + const index: ServerModIndex = { mods: [] }; + + for (const mod of mods) { + const manifest = mod.manifest as { name: string; version: string; scripts?: Record }; + const scripts = manifest.scripts ?? {}; + + const data: ModData = { + blocks: mod.blocks.map((b) => b.json), + items: mod.items.map((i) => i.json), + recipes: mod.recipes.map((r) => r.json), + ores: mod.ores.map((o) => o.json), + }; + const data_json = JSON.stringify(data); + const client = scripts.client ? await bundle_script(`${mod.dir}/${scripts.client}`, "browser") : undefined; + const worldgen = scripts.worldgen + ? await bundle_script(`${mod.dir}/${scripts.worldgen}`, "browser") + : undefined; + const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined; + + const hash = await short_hash([data_json, client ?? "", worldgen ?? ""]); + const public_dir = `mods/${mod.id}/${hash}`; + Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true }); + + const listing: ModListing = { + id: mod.id, + name: manifest.name, + version: manifest.version, + hash, + data: `${public_dir}/data.json`, + }; + Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json); + if (client) { + listing.client = `${public_dir}/client.js`; + Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client); + } + if (worldgen) { + listing.worldgen = `${public_dir}/worldgen.js`; + Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen); + } + + const entry: ServerModIndex["mods"][number] = { listing }; + if (server) { + const server_dir = `${SERVER_MODS_FOLDER}/${mod.id}/${await short_hash([server])}`; + Deno.mkdirSync(server_dir, { recursive: true }); + entry.server = `${server_dir}/server.js`; + Deno.writeTextFileSync(entry.server, server); + } + index.mods.push(entry); + } + + Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t")); + console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`); } async function build_client() { @@ -130,20 +232,32 @@ async function build_client() { async function build() { try { const now = performance.now(); - clear_folder(); - await build_assets(); + clear_folder(BUILD_FOLDER); + const mods = load_mods(); + await build_assets(mods); await build_client(); + await build_mods(mods); console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`); } catch (e) { - console.log(e); + // no index means the server refuses to start, instead of running without some mods + try { + Deno.removeSync(`${SERVER_MODS_FOLDER}/index.json`); + } catch { /* wasn't there */ } + console.log(e instanceof Error ? e.message : e); + return false; } + return true; } let last_build = 0; if (import.meta.main) { - await build(); - const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true }); + const ok = await build(); + // deno task build --once, for scripts and ci + if (Deno.args.includes("--once")) { + Deno.exit(ok ? 0 : 1); + } + const watcher = Deno.watchFs(["assets", "client", "common", MODS_FOLDER], { recursive: true }); for await (const event of watcher) { const now = performance.now(); if (now - last_build < 500) { diff --git a/client/components/dimension.ts b/client/components/dimension.ts index 0436d29..d95b4f2 100644 --- a/client/components/dimension.ts +++ b/client/components/dimension.ts @@ -6,6 +6,7 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from import { AssetManager } from "../assets.ts"; import { ClientWorld } from "../client_world.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts"; +import { worldgen_mods } from "../mods.ts"; import type { FromChunkWorker } from "../workers/chunk_messages.ts"; import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts"; import { Camera } from "./camera.ts"; @@ -69,6 +70,8 @@ export class Dimension extends Component { block_ids, textures_info: AssetManager.instance.get("bworld:textures_info"), image: { width: this.image.width, height: this.image.height }, + worldgen_scripts: worldgen_mods.scripts, + ores: worldgen_mods.ores, }); } diff --git a/client/main.ts b/client/main.ts index 1d57904..b514cc9 100644 --- a/client/main.ts +++ b/client/main.ts @@ -4,9 +4,7 @@ import { InputManager } from "./input_manager.ts"; import { Connection, get_player_name, get_server_url } from "./network.ts"; import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts"; import { is_stopped, show_fatal_error } from "./fatal.ts"; - -await import("$/common/blocks/mod.ts"); -await import("$/common/items/mod.ts"); +import { load_client_mods, set_mods_world } from "./mods.ts"; export class ClientLoop { running = false; @@ -94,16 +92,30 @@ init_font(); // the game only runs against a server, it owns the world and everything in it const connection = await Connection.open(get_server_url(), get_player_name()); +let mods_loaded = false; if (connection) { console.log(`Connected to ${get_server_url()}`); + try { + // blocks, items and textures all come from mods, so this happens before anything else + await load_client_mods(connection.mods); + console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`); + mods_loaded = true; + } catch (e) { + console.error(e); + show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`); + connection.socket.close(); + } +} +if (connection && mods_loaded) { const client_world = new ClientWorld(connection); + set_mods_world(client_world); client_world.add_chat("Connected to the server"); const loop = new ClientLoop(client_world); loop.start(); console.log("Game started"); -} else { +} else if (!connection) { show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`); } diff --git a/client/mods.ts b/client/mods.ts new file mode 100644 index 0000000..8a1125f --- /dev/null +++ b/client/mods.ts @@ -0,0 +1,87 @@ +// loads the mods the server lists: registers their data, then runs their client scripts. +// see "Delivery to clients" in MODS.md +import { AIR } from "$/common/constants.ts"; +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import type { ClientContext } from "$/common/mod_api/client.ts"; +import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts"; +import type { OreJson } from "$/common/mod_data.ts"; +import { AIR_ID } from "$/common/protocol.ts"; +import { Position } from "$/common/components/position.ts"; +import type { ClientWorld } from "./client_world.ts"; + +// what the chunk workers need to generate the same world as the server +export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] }; + +// set once the world exists, mods can't look at it during setup +let world: ClientWorld | undefined; + +export function set_mods_world(client_world: ClientWorld) { + world = client_world; +} + +export async function load_client_mods(listings: ModListing[]) { + const site = new URL("/", location.href); + + const datas = await Promise.all(listings.map(async (listing) => { + const response = await fetch(new URL(listing.data, site)); + if (!response.ok) { + throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`); + } + return { id: listing.id, data: await response.json() as ModData }; + })); + const recipes = register_mod_data(datas); + + worldgen_mods.ores = recipes.ores; + worldgen_mods.scripts = listings.flatMap((listing) => + listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : [] + ); + + for (const listing of listings) { + if (!listing.client) continue; + const module = await import(new URL(listing.client, site).href); + if (typeof module.setup !== "function") { + throw new ModLoadError(listing.id, "the client script doesn't export a setup function"); + } + await module.setup(client_context(listing)); + } +} + +function client_context(listing: ModListing): ClientContext { + const mod = listing.id; + const not_yet = (name: string, where: string) => + new Proxy({}, { + get: (_, prop) => () => { + throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`); + }, + }); + const need_world = () => { + if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`); + return world; + }; + + return { + mod: { id: mod, version: listing.version }, + ui: not_yet("ui", "step 7") as ClientContext["ui"], + hud: not_yet("hud", "step 7") as ClientContext["hud"], + input: not_yet("input", "step 8") as ClientContext["input"], + net: not_yet("net", "step 8") as ClientContext["net"], + player: { + get name() { + return need_world().connection.name; + }, + get position() { + const [player] = need_world().get_tag("player")!; + const position = player.get(Position)!; + return { x: position.x, y: position.y, z: position.z }; + }, + }, + world: { + get_block(x, y, z) { + const nid = need_world().dimension.get_block(x, y, z); + if (nid === AIR) return AIR_ID; + return EverythingRegistry.get_by_id("blocks", nid)?.id; + }, + }, + log: (...args) => console.log(`[${mod}]`, ...args), + }; +} diff --git a/client/network.ts b/client/network.ts index 2e99fc9..f15e394 100644 --- a/client/network.ts +++ b/client/network.ts @@ -1,4 +1,5 @@ import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts"; +import type { ModListing } from "$/common/mod_loader.ts"; const CONNECT_TIMEOUT_MS = 3000; @@ -13,7 +14,9 @@ export interface RemotePlayer extends PlayerInfo { export class Connection { socket: WebSocket; id: string; + name: string; seed: string; + mods: ModListing[]; initial_changes: BlockChange[]; spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; selected_slot: number; @@ -26,7 +29,9 @@ export class Connection { constructor(socket: WebSocket, welcome: Extract) { this.socket = socket; this.id = welcome.id; + this.name = welcome.name; this.seed = welcome.seed; + this.mods = welcome.mods; this.initial_changes = welcome.changes; this.spawn = welcome.spawn; this.selected_slot = welcome.selected_slot; diff --git a/client/systems/network_system.ts b/client/systems/network_system.ts index edb8e8f..47ddecd 100644 --- a/client/systems/network_system.ts +++ b/client/systems/network_system.ts @@ -75,6 +75,13 @@ export class NetworkSystem extends System { } break; } + case "teleport": { + const position = local_player.get(Position)!; + position.x = message.x; + position.y = message.y; + position.z = message.z; + break; + } case "close_screen": // closed by the server (the block broke), it already put everything back player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer)); diff --git a/client/systems/rendering/player.ts b/client/systems/rendering/player.ts index 7b087ca..f752c02 100644 --- a/client/systems/rendering/player.ts +++ b/client/systems/rendering/player.ts @@ -102,7 +102,7 @@ export function render_player_breaking(player_component: PlayerComponent) { if (Number.isNaN(break_sprite)) { return; } - const region = get_sprite_region(`bworld:break_${break_sprite}`); + const region = get_sprite_region(`engine:break_${break_sprite}`); for (const fn of FACE_FUNCTIONS) { fn( diff --git a/client/systems/rendering/render_utils.ts b/client/systems/rendering/render_utils.ts index adcd919..08dcdbd 100644 --- a/client/systems/rendering/render_utils.ts +++ b/client/systems/rendering/render_utils.ts @@ -231,7 +231,7 @@ export function draw_item(item: ItemStack, x: number, y: number) { // ey its not a bad name function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) { - let texture_id = "bworld:missing"; + let texture_id = "engine:missing"; if (typeof item_info?.texture_id === "string") { texture_id = item_info.texture_id; } else if (typeof item_info?.texture_id === "function") { @@ -256,9 +256,9 @@ function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y if (!block_info) throw new Error(`no textures for ${item_info.block_id}`); - let front_texture = "bworld:missing"; - let top_texture = "bworld:missing"; - let left_texture = "bworld:missing"; + let front_texture = "engine:missing"; + let top_texture = "engine:missing"; + let left_texture = "engine:missing"; const textures = block_info.textures; diff --git a/client/workers/chunk_messages.ts b/client/workers/chunk_messages.ts index cdc1480..709f30f 100644 --- a/client/workers/chunk_messages.ts +++ b/client/workers/chunk_messages.ts @@ -1,5 +1,6 @@ import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { SpriteRegion } from "$/common/constants.ts"; +import type { OreJson } from "$/common/mod_data.ts"; // messages between the main thread and the chunk workers @@ -11,6 +12,9 @@ export type ToChunkWorker = block_ids: Record; textures_info: Record; image: { width: number; height: number }; + // mods' worldgen scripts and ores, so generation matches the server + worldgen_scripts: { mod: string; url: string }[]; + ores: OreJson[]; } | { type: "generate"; chunk_x: number; chunk_z: number; seed: string } | { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array }; diff --git a/client/workers/chunk_worker.ts b/client/workers/chunk_worker.ts index 37d5c25..b4c12ad 100644 --- a/client/workers/chunk_worker.ts +++ b/client/workers/chunk_worker.ts @@ -4,7 +4,8 @@ import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { SpriteRegion } from "$/common/constants.ts"; import type { Texture } from "../renderer/types.ts"; import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts"; -import { generate_raw_chunk } from "$/common/generation.ts"; +import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts"; +import { load_worldgen } from "$/common/worldgen_loader.ts"; const pad = 0.5; @@ -266,8 +267,11 @@ let blocks_registry: BlockRegistry[] = []; let block_ids: Record = {}; let textures_info: TexturesInfo = {}; let image: Texture; +let worldgen: WorldgenSetup | undefined; +// generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's +let worldgen_ready: Promise = Promise.resolve(); -self.onmessage = (event: MessageEvent) => { +self.onmessage = async (event: MessageEvent) => { const message = event.data; switch (message.type) { case "init": @@ -275,8 +279,12 @@ self.onmessage = (event: MessageEvent) => { block_ids = message.block_ids; textures_info = message.textures_info; image = message.image as Texture; + worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => { + worldgen = setup; + }); break; case "generate": + await worldgen_ready; generate(message.chunk_x, message.chunk_z, message.seed); break; case "mesh": { @@ -311,7 +319,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) { } function generate(chunk_x: number, chunk_z: number, seed: string) { - const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids); + const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen); post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]); } @@ -353,12 +361,12 @@ function make_chunk_mesh( const block_info = blocks_registry[block_nid]; const texture_ids = { - top: "bworld:missing", - bottom: "bworld:missing", - front: "bworld:missing", - back: "bworld:missing", - left: "bworld:missing", - right: "bworld:missing", + top: "engine:missing", + bottom: "engine:missing", + front: "engine:missing", + back: "engine:missing", + left: "engine:missing", + right: "engine:missing", }; const textures = block_info.textures; diff --git a/common/blocks/chest.ts b/common/blocks/chest.ts deleted file mode 100644 index 31042a6..0000000 --- a/common/blocks/chest.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -// behavior lives in server/game/blocks.ts -const block = EverythingRegistry.register("blocks", "bworld:chest", { - id: "bworld:chest", - textures: "bworld:planks", - toughness: 8, - requires_tool: false, - tool_to_break: "axe", - drop_table: "bworld:chest", - has_collision: false, - interactive: true, -}); - -register_block_item(block); diff --git a/common/blocks/coal_ore.ts b/common/blocks/coal_ore.ts deleted file mode 100644 index 3a67e96..0000000 --- a/common/blocks/coal_ore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:coal_ore", { - id: "bworld:coal_ore", - textures: "bworld:stone_coal", - has_collision: true, - drop_table: "bworld:coal_ore", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/copper_ore.ts b/common/blocks/copper_ore.ts deleted file mode 100644 index f9cd529..0000000 --- a/common/blocks/copper_ore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:copper_ore", { - id: "bworld:copper_ore", - textures: "bworld:stone_copper", - has_collision: true, - drop_table: "bworld:copper_ore", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/dirt.ts b/common/blocks/dirt.ts deleted file mode 100644 index 5faf4f0..0000000 --- a/common/blocks/dirt.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -// hoeing it is handled in server/game/blocks.ts -const block = EverythingRegistry.register("blocks", "bworld:dirt", { - id: "bworld:dirt", - textures: "bworld:dirt", - has_collision: false, - drop_table: "bworld:dirt", - toughness: 2, - requires_tool: false, - tool_to_break: "shovel", -}); - -register_block_item(block); diff --git a/common/blocks/furnace.ts b/common/blocks/furnace.ts deleted file mode 100644 index add1cd4..0000000 --- a/common/blocks/furnace.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -// behavior lives in server/game/blocks.ts -const block = EverythingRegistry.register("blocks", "bworld:furnace", { - id: "bworld:furnace", - textures: { front: "bworld:furnace", side: "bworld:stone" }, - has_collision: true, - drop_table: "bworld:furnace", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", - interactive: true, -}); - -register_block_item(block); diff --git a/common/blocks/glass.ts b/common/blocks/glass.ts deleted file mode 100644 index 428f1ac..0000000 --- a/common/blocks/glass.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:glass", { - id: "bworld:glass", - textures: "bworld:glass", - has_collision: true, - transparent: true, - toughness: 3, - requires_tool: false, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/gold_ore.ts b/common/blocks/gold_ore.ts deleted file mode 100644 index 84a5955..0000000 --- a/common/blocks/gold_ore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:gold_ore", { - id: "bworld:gold_ore", - textures: "bworld:stone_gold", - has_collision: true, - drop_table: "bworld:gold_ore", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/grass.ts b/common/blocks/grass.ts deleted file mode 100644 index 46a7d95..0000000 --- a/common/blocks/grass.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; - -// hoeing it is handled in server/game/blocks.ts -EverythingRegistry.register("blocks", "bworld:grass", { - id: "bworld:grass", - textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" }, - has_collision: false, - drop_table: "bworld:dirt", - toughness: 2, - requires_tool: false, - tool_to_break: "shovel", -}); diff --git a/common/blocks/hoed_dirt.ts b/common/blocks/hoed_dirt.ts deleted file mode 100644 index 0e1963d..0000000 --- a/common/blocks/hoed_dirt.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -// TODO: watered state -const block = EverythingRegistry.register("blocks", "bworld:hoed_dirt", { - id: "bworld:hoed_dirt", - textures: { side: "bworld:dirt", top: "bworld:hoed_dirt", bottom: "bworld:dirt" }, - has_collision: false, - drop_table: "bworld:dirt", - toughness: 5, - requires_tool: false, - tool_to_break: "shovel", - states: [ - { name: "watered", bits: 1, default: 0 }, - ], -}); - -register_block_item(block); diff --git a/common/blocks/iron_ore.ts b/common/blocks/iron_ore.ts deleted file mode 100644 index 345f15e..0000000 --- a/common/blocks/iron_ore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:iron_ore", { - id: "bworld:iron_ore", - textures: "bworld:stone_iron", - has_collision: true, - drop_table: "bworld:iron_ore", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/leaves.ts b/common/blocks/leaves.ts deleted file mode 100644 index 5ae0e7f..0000000 --- a/common/blocks/leaves.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:leaves", { - id: "bworld:leaves", - textures: "bworld:leaves", - has_collision: true, - transparent: true, - toughness: 3, - requires_tool: false, - tool_to_break: "hoe", -}); - -register_block_item(block); diff --git a/common/blocks/log.ts b/common/blocks/log.ts deleted file mode 100644 index 0104d87..0000000 --- a/common/blocks/log.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:log", { - id: "bworld:log", - textures: { side: "bworld:log_side", top: "bworld:log_top", bottom: "bworld:log_top" }, - has_collision: true, - drop_table: "bworld:log", - toughness: 3, - requires_tool: false, - tool_to_break: "axe", -}); - -register_block_item(block); diff --git a/common/blocks/mod.ts b/common/blocks/mod.ts deleted file mode 100644 index 95d8c33..0000000 --- a/common/blocks/mod.ts +++ /dev/null @@ -1,19 +0,0 @@ -import "./grass.ts"; -import "./dirt.ts"; -import "./crops.ts"; -import "./water.ts"; -import "./chest.ts"; -import "./furnace.ts"; -import "./stone.ts"; -import "./log.ts"; -import "./sand.ts"; -import "./snow.ts"; -import "./glass.ts"; -import "./hoed_dirt.ts"; -import "./leaves.ts"; -import "./coal_ore.ts"; -import "./copper_ore.ts"; -import "./iron_ore.ts"; -import "./tin_ore.ts"; -import "./gold_ore.ts"; -import "./planks.ts"; diff --git a/common/blocks/planks.ts b/common/blocks/planks.ts deleted file mode 100644 index 8c735a8..0000000 --- a/common/blocks/planks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:planks", { - id: "bworld:planks", - textures: "bworld:planks", - has_collision: true, - drop_table: "bworld:log", - toughness: 3, - requires_tool: false, - tool_to_break: "axe", -}); - -register_block_item(block); diff --git a/common/blocks/sand.ts b/common/blocks/sand.ts deleted file mode 100644 index be92179..0000000 --- a/common/blocks/sand.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:sand", { - id: "bworld:sand", - textures: "bworld:sand", - has_collision: true, - drop_table: "bworld:sand", - toughness: 3, - requires_tool: false, - tool_to_break: "shovel", -}); - -register_block_item(block); diff --git a/common/blocks/snow.ts b/common/blocks/snow.ts deleted file mode 100644 index f9a01b4..0000000 --- a/common/blocks/snow.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:snow", { - id: "bworld:snow", - textures: "bworld:snow", - has_collision: true, - toughness: 2, - requires_tool: true, - tool_to_break: "shovel", -}); - -register_block_item(block); diff --git a/common/blocks/stone.ts b/common/blocks/stone.ts deleted file mode 100644 index d5abd89..0000000 --- a/common/blocks/stone.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:stone", { - id: "bworld:stone", - textures: "bworld:stone", - has_collision: true, - drop_table: "bworld:stone", - toughness: 3, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/tin_ore.ts b/common/blocks/tin_ore.ts deleted file mode 100644 index b08c366..0000000 --- a/common/blocks/tin_ore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:tin_ore", { - id: "bworld:tin_ore", - textures: "bworld:stone_tin", - has_collision: true, - drop_table: "bworld:tin_ore", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", -}); - -register_block_item(block); diff --git a/common/blocks/water.ts b/common/blocks/water.ts deleted file mode 100644 index 49d1e23..0000000 --- a/common/blocks/water.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("blocks", "bworld:water", { - id: "bworld:water", - textures: "bworld:water", - has_collision: false, - transparent: true, - alpha: 0.8, - replaceable: true, -}); diff --git a/common/everything_registry.ts b/common/everything_registry.ts index 9447d9d..0071d97 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -5,6 +5,9 @@ export class EverythingRegistry { static #id_to_value = new Map(); static register(registry: string, key: string, value: T): T { + if (this.#key_to_id.get(registry)?.has(key)) { + throw new Error(`${key} is already registered in ${registry}`); + } if (!this.#key_to_id.has(registry)) { this.#key_to_id.set(registry, new Map()); this.#id_to_value.set(registry, []); @@ -49,6 +52,12 @@ export class EverythingRegistry { return this.#id_to_value.get(registry) as T[]; } + // forget everything, for tests that load mods more than once + static clear() { + this.#key_to_id.clear(); + this.#id_to_value.clear(); + } + // [key, value] pairs in registration order static entries(registry: string): [string, T][] { const values = this.#id_to_value.get(registry) ?? []; @@ -104,6 +113,8 @@ export interface BlockRegistry { interactive?: boolean; // placing a block into it replaces it, like water replaceable?: boolean; + // custom components from mods and their params, the server runs them + components?: Record; compiled_states?: CompiledStateDefinition[]; } @@ -112,6 +123,9 @@ export interface ItemRegistry { texture_id: string | ((item: ItemStack) => string); block_id?: string; tool_type?: string; + max_stack?: number; + lore?: string; + components?: Record; on_create?(item: ItemStack): void; diff --git a/common/generation.ts b/common/generation.ts index bdb9e05..ec5e96c 100644 --- a/common/generation.ts +++ b/common/generation.ts @@ -1,9 +1,13 @@ import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; -import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts"; +import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts"; +import type { FeatureChunk } from "$/common/mod_api/worldgen.ts"; +import type { OreJson } from "$/common/mod_data.ts"; // generation runs in a worker now, so it only needs somewhere to put blocks export interface BlockSink { add_block(block: { x: number; y: number; z: number; id: string }): void; + // the surface height and biome of each column, for later passes + set_column?(x: number, z: number, height: number, biome: string): void; } type Biome = @@ -226,6 +230,7 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see const height = get_terrain_height(base_height, biome, wx, wz, height_noise); const surface_block = get_surface_block(biome); + dimension.set_column?.(wx, wz, height, `bworld:${biome}`); for (let y = 0; y <= height; y++) { let block = "bworld:stone"; @@ -270,6 +275,36 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see } } +// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's +const mod_noise_2d = new Map(); +const mod_noise_3d = new Map(); + +export function named_noise_2d(seed: string, name: string): NoiseFunction2D { + const key = `${seed}_${name}`; + let noise = mod_noise_2d.get(key); + if (!noise) { + noise = create_noise_2d(new Alea(key)); + mod_noise_2d.set(key, noise); + } + return noise; +} + +export function named_noise_3d(seed: string, name: string): NoiseFunction3D { + const key = `${seed}_${name}`; + let noise = mod_noise_3d.get(key); + if (!noise) { + noise = create_noise_3d(new Alea(key)); + mod_noise_3d.set(key, noise); + } + return noise; +} + +// what mods add to generation, see "World generation" in MODS.md +export interface WorldgenSetup { + ores: OreJson[]; + features: { id: string; generate: (chunk: FeatureChunk) => void }[]; +} + export interface RawChunk { // numeric block ids, only what this chunk generated itself blocks: Uint32Array; @@ -277,6 +312,9 @@ export interface RawChunk { spills: Int32Array; } +// features that threw, so each is only reported once +const failed_features = new Set(); + // generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world: // a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order export function generate_raw_chunk( @@ -284,26 +322,40 @@ export function generate_raw_chunk( chunk_z: number, seed: string, block_ids: Record, + worldgen?: WorldgenSetup, ): RawChunk { const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); const spills: number[] = []; + const heights = new Int32Array(CHUNK_AREA); + const biomes: string[] = new Array(CHUNK_AREA); + + const set = (x: number, y: number, z: number, nid: number) => { + if (y < 0 || y >= CHUNK_HEIGHT) { + return; + } + const block_chunk_x = Math.floor(x / CHUNK_SIZE); + const block_chunk_z = Math.floor(z / CHUNK_SIZE); + if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { + spills.push(x, y, z, nid); + return; + } + const lx = x - chunk_x * CHUNK_SIZE; + const lz = z - chunk_z * CHUNK_SIZE; + blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; + }; generate_chunk( { add_block(block) { const nid = block_ids[block.id]; - if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) { - return; + if (nid !== undefined) { + set(block.x, block.y, block.z, nid); } - const block_chunk_x = Math.floor(block.x / CHUNK_SIZE); - const block_chunk_z = Math.floor(block.z / CHUNK_SIZE); - if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { - spills.push(block.x, block.y, block.z, nid); - return; - } - const lx = block.x - chunk_x * CHUNK_SIZE; - const lz = block.z - chunk_z * CHUNK_SIZE; - blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = nid; + }, + set_column(x, z, height, biome) { + const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); + heights[index] = height; + biomes[index] = biome; }, }, chunk_x, @@ -311,5 +363,119 @@ export function generate_raw_chunk( seed, ); + if (worldgen) { + generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores); + generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features); + } + return { blocks, spills: new Int32Array(spills) }; } + +// each block an ore replaces becomes the first ore whose noise is above its threshold +function generate_ores( + blocks: Uint32Array, + chunk_x: number, + chunk_z: number, + seed: string, + block_ids: Record, + ores: OreJson[], +) { + const usable = ores + .map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] })) + .filter((ore) => ore.nid !== undefined && ore.replaces_nid !== undefined); + if (usable.length === 0) { + return; + } + const noises = usable.map((ore) => named_noise_3d(seed, ore.id)); + + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let lz = 0; lz < CHUNK_SIZE; lz++) { + for (let lx = 0; lx < CHUNK_SIZE; lx++) { + const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; + const current = blocks[index]; + if (current === AIR) { + continue; + } + for (let i = 0; i < usable.length; i++) { + const ore = usable[i]; + if (current !== ore.replaces_nid || y < ore.min_y || y > ore.max_y) { + continue; + } + const wx = chunk_x * CHUNK_SIZE + lx; + const wz = chunk_z * CHUNK_SIZE + lz; + if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) { + blocks[index] = ore.nid; + break; + } + } + } + } + } +} + +function generate_features( + blocks: Uint32Array, + heights: Int32Array, + biomes: string[], + set: (x: number, y: number, z: number, nid: number) => void, + chunk_x: number, + chunk_z: number, + seed: string, + block_ids: Record, + features: WorldgenSetup["features"], +) { + const ids_by_nid: string[] = []; + for (const [id, nid] of Object.entries(block_ids)) { + ids_by_nid[nid] = id; + } + + const local = (x: number, z: number, what: string) => { + const lx = x - chunk_x * CHUNK_SIZE; + const lz = z - chunk_z * CHUNK_SIZE; + if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) { + throw new Error(`${what}(${x}, ${z}) is outside chunk ${chunk_x}, ${chunk_z}`); + } + return lz * CHUNK_SIZE + lx; + }; + + for (const feature of features) { + const chunk: FeatureChunk = { + x: chunk_x, + z: chunk_z, + seed, + rng: new Alea(`${seed}_feature_${feature.id}_${chunk_x}_${chunk_z}`), + noise_2d: (name) => named_noise_2d(seed, name), + noise_3d: (name) => named_noise_3d(seed, name), + height_at: (x, z) => heights[local(x, z, "height_at")], + biome_at: (x, z) => biomes[local(x, z, "biome_at")], + get_block(x, y, z) { + if (y < 0 || y >= CHUNK_HEIGHT) { + return undefined; + } + const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")]; + return nid === AIR ? "bworld:air" : ids_by_nid[nid]; + }, + set_block(x, y, z, id) { + const nid = id === "bworld:air" ? AIR : block_ids[id]; + if (nid === undefined) { + throw new Error(`unknown block ${id}`); + } + const dx = Math.floor(x / CHUNK_SIZE) - chunk_x; + const dz = Math.floor(z / CHUNK_SIZE) - chunk_z; + if (Math.abs(dx) > 1 || Math.abs(dz) > 1) { + throw new Error(`set_block(${x}, ${y}, ${z}) is more than one chunk away`); + } + set(x, y, z, nid); + }, + }; + try { + feature.generate(chunk); + } catch (e) { + // every client and the server hit the same error, so skipping it keeps them in agreement + if (!failed_features.has(feature.id)) { + failed_features.add(feature.id); + console.error(`Worldgen feature ${feature.id} failed, skipping it where it throws:`, e); + } + } + } +} diff --git a/common/inventory.ts b/common/inventory.ts index f00d42b..bb66e39 100644 --- a/common/inventory.ts +++ b/common/inventory.ts @@ -13,12 +13,12 @@ export class ItemStack { max_amount: number; data?: T; - constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { + constructor(type_id: string | string, amount: number = 1, max_amount?: number) { + const item_info = EverythingRegistry.get("items", type_id); this.type_id = type_id; this.amount = amount; - this.max_amount = max_amount; + this.max_amount = max_amount ?? item_info?.max_stack ?? 64; this.data = undefined; - const item_info = EverythingRegistry.get("items", type_id); if (item_info?.on_create) { item_info.on_create(this); } diff --git a/common/items/axe.ts b/common/items/axe.ts deleted file mode 100644 index ca9c362..0000000 --- a/common/items/axe.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:axe", { - texture_id: "bworld:axe", - tool_type: "axe", -}); diff --git a/common/items/coal.ts b/common/items/coal.ts deleted file mode 100644 index 58df625..0000000 --- a/common/items/coal.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:coal", { - texture_id: "bworld:coal", -}); diff --git a/common/items/copper_ingot.ts b/common/items/copper_ingot.ts deleted file mode 100644 index 45cab7d..0000000 --- a/common/items/copper_ingot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:copper_ingot", { - texture_id: "bworld:copper_ingot", -}); diff --git a/common/items/gold_ingot.ts b/common/items/gold_ingot.ts deleted file mode 100644 index 0a536e7..0000000 --- a/common/items/gold_ingot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:gold_ingot", { - texture_id: "bworld:gold_ingot", -}); diff --git a/common/items/hoe.ts b/common/items/hoe.ts deleted file mode 100644 index bd09204..0000000 --- a/common/items/hoe.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:hoe", { - texture_id: "bworld:hoe", -}); diff --git a/common/items/iron_ingot.ts b/common/items/iron_ingot.ts deleted file mode 100644 index 2d14006..0000000 --- a/common/items/iron_ingot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:iron_ingot", { - texture_id: "bworld:iron_ingot", -}); diff --git a/common/items/mod.ts b/common/items/mod.ts deleted file mode 100644 index b3aa64b..0000000 --- a/common/items/mod.ts +++ /dev/null @@ -1,11 +0,0 @@ -import "./watering_can.ts"; -import "./axe.ts"; -import "./pickaxe.ts"; -import "./hoe.ts"; -import "./coal.ts"; -import "./tin_ingot.ts"; -import "./iron_ingot.ts"; -import "./copper_ingot.ts"; -import "./gold_ingot.ts"; -import "./stick.ts"; -import "./wood_pickaxe.ts"; diff --git a/common/items/pickaxe.ts b/common/items/pickaxe.ts deleted file mode 100644 index 29b3465..0000000 --- a/common/items/pickaxe.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:pickaxe", { - texture_id: "bworld:pickaxe", -}); diff --git a/common/items/stick.ts b/common/items/stick.ts deleted file mode 100644 index 3ff679d..0000000 --- a/common/items/stick.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:stick", { - texture_id: "bworld:stick", -}); diff --git a/common/items/tin_ingot.ts b/common/items/tin_ingot.ts deleted file mode 100644 index 0f5578e..0000000 --- a/common/items/tin_ingot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:tin_ingot", { - texture_id: "bworld:tin_ingot", -}); diff --git a/common/items/watering_can.ts b/common/items/watering_can.ts deleted file mode 100644 index ba3d59c..0000000 --- a/common/items/watering_can.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -export interface WateringCanData { - water: number; - max_water: number; -} - -EverythingRegistry.register>("items", "bworld:watering_can", { - texture_id: "bworld:watering_can", - on_create(item) { - item.data = { water: 0, max_water: 32 }; - }, - get_lore(item) { - return `Water: ${item.data?.water}/${item.data?.max_water}`; - }, -}); diff --git a/common/items/wood_pickaxe.ts b/common/items/wood_pickaxe.ts deleted file mode 100644 index 7ac7b5f..0000000 --- a/common/items/wood_pickaxe.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; - -EverythingRegistry.register("items", "bworld:wood_pickaxe", { - texture_id: "bworld:wood_pickaxe", - tool_type: "pickaxe", -}); diff --git a/common/mod_data.ts b/common/mod_data.ts index 0a6c125..6335197 100644 --- a/common/mod_data.ts +++ b/common/mod_data.ts @@ -1,5 +1,5 @@ // the json formats from MODS.md, and converting them to and from the engine's registry entries. -// the mod loader uses the from_json direction, tools/export_bworld_mod.ts the other one +// the mod loader uses the from_json direction, tests use both to check nothing is lost import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts"; export const FORMAT_VERSION = 1; @@ -37,6 +37,15 @@ export interface BlockJson { components?: Record; } +export interface OreJson { + id: string; + replaces: string; + min_y: number; + max_y: number; + scale: number; + threshold: number; +} + export interface ItemJson { id: string; texture: string; @@ -82,6 +91,7 @@ export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJso if (block.interactive) json.interactive = true; if (block.replaceable) json.replaceable = true; if (block.states) json.states = block.states; + if (block.components) json.components = block.components; return json; } @@ -102,6 +112,7 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it if (json.interactive) block.interactive = true; if (json.replaceable) block.replaceable = true; if (json.states) block.states = json.states; + if (json.components) block.components = json.components; return { block, has_item: json.item ?? true }; } @@ -114,6 +125,9 @@ export function item_to_json(id: string, item: ItemRegistry): ItemJson { const json: ItemJson = { id, texture: item.texture_id }; if (item.tool_type !== undefined) json.tool = item.tool_type; if (item.block_id !== undefined) json.places = item.block_id; + if (item.max_stack !== undefined) json.max_stack = item.max_stack; + if (item.lore !== undefined) json.lore = item.lore; + if (item.components) json.components = item.components; return json; } @@ -121,6 +135,9 @@ export function item_from_json(json: ItemJson): ItemRegistry { const item: ItemRegistry = { texture_id: json.texture }; if (json.tool !== undefined) item.tool_type = json.tool; if (json.places !== undefined) item.block_id = json.places; + if (json.max_stack !== undefined) item.max_stack = json.max_stack; + if (json.lore !== undefined) item.lore = json.lore; + if (json.components) item.components = json.components; return item; } @@ -303,6 +320,18 @@ export function validate_recipe(json: unknown): Problems { return problems; } +export function validate_ore(json: unknown): Problems { + if (!is_object(json)) return ["ore must be an object"]; + const problems = [...validate_id(json.id, "id"), ...validate_id(json.replaces, "replaces")]; + for (const key of ["min_y", "max_y", "scale", "threshold"]) { + if (typeof json[key] !== "number") problems.push(`${key} must be a number`); + } + if (typeof json.min_y === "number" && typeof json.max_y === "number" && json.min_y > json.max_y) { + problems.push("min_y must not be above max_y"); + } + return problems; +} + function validate_id(value: unknown, field: string): Problems { return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`]; } diff --git a/common/mod_loader.ts b/common/mod_loader.ts new file mode 100644 index 0000000..e59b43c --- /dev/null +++ b/common/mod_loader.ts @@ -0,0 +1,89 @@ +// registers mods' data, the same way on the server and on clients +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; +import { + block_from_json, + BlockJson, + grid_recipe_from_json, + GridRecipe, + item_from_json, + ItemJson, + OreJson, + RecipeJson, +} from "./mod_data.ts"; +import { register_block_item } from "./utils.ts"; + +// everything a mod's json files hold, merged into one file by the build (build/mods///data.json) +export interface ModData { + blocks: BlockJson[]; + items: ItemJson[]; + recipes: RecipeJson[]; + ores: OreJson[]; +} + +// a mod as the server lists it to clients, in load order. paths are relative to the site root +export interface ModListing { + id: string; + name: string; + version: string; + hash: string; + data: string; + client?: string; + worldgen?: string; +} + +export class RecipeBook { + shaped: GridRecipe[] = []; + furnace = new Map(); + fuel = new Map(); + ores: OreJson[] = []; +} + +export class ModLoadError extends Error { + constructor(mod: string, message: string) { + super(`${mod}: ${message}`); + this.name = "ModLoadError"; + } +} + +// registers every mod's blocks, items and recipes, in the order given (dependencies first) +export function register_mod_data(mods: { id: string; data: ModData }[]): RecipeBook { + const recipes = new RecipeBook(); + + for (const { id, data } of mods) { + const fail = (message: string): never => { + throw new ModLoadError(id, message); + }; + try { + for (const json of data.blocks) { + const { block, has_item } = block_from_json(json); + EverythingRegistry.register("blocks", block.id, block); + if (has_item) { + register_block_item(block); + } + } + for (const json of data.items) { + EverythingRegistry.register("items", json.id, item_from_json(json)); + } + } catch (e) { + fail((e as Error).message); + } + + for (const recipe of data.recipes) { + switch (recipe.type) { + case "shaped": + recipes.shaped.push(grid_recipe_from_json(recipe)); + break; + case "furnace": + if (recipes.furnace.has(recipe.input)) fail(`two furnace recipes for ${recipe.input}`); + recipes.furnace.set(recipe.input, { output: recipe.output, cook_time: recipe.cook_time }); + break; + case "fuel": + recipes.fuel.set(recipe.item, recipe.burn_time); + break; + } + } + recipes.ores.push(...data.ores); + } + + return recipes; +} diff --git a/common/protocol.ts b/common/protocol.ts index a25c8b5..cbea30f 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -1,6 +1,7 @@ // messages sent between the client and the server, as json over a websocket import type { Faces } from "./constants.ts"; import type { ItemData } from "./inventory.ts"; +import type { ModListing } from "./mod_loader.ts"; export const AIR_ID = "bworld:air"; @@ -58,7 +59,11 @@ export type ServerMessage = | { type: "welcome"; id: string; + // the server may change the name asked for, like alice to alice2 + name: string; seed: string; + // in load order, the client loads them before joining the world + mods: ModListing[]; players: PlayerInfo[]; changes: BlockChange[]; spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; @@ -70,6 +75,7 @@ export type ServerMessage = // also sent to the player who caused it, which corrects anything their client predicted wrong | { type: "set_block"; x: number; y: number; z: number; id: string } | { type: "chat"; from?: string; text: string } + | { type: "teleport"; x: number; y: number; z: number } | { type: "container"; container: ContainerKey; items: (ItemData | null)[] } | { type: "cursor"; item: ItemData | null } | { type: "open_screen"; layout: ScreenLayout; properties: Record } diff --git a/common/utils.ts b/common/utils.ts index b2dc8e6..ba1b051 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -39,7 +39,7 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num export function register_block_item(block: BlockRegistry) { // TODO: handle block textures - let texture_id = "bworld:missing"; + let texture_id = "engine:missing"; if (typeof block.textures === "string") { texture_id = block.textures; } diff --git a/common/worldgen_loader.ts b/common/worldgen_loader.ts new file mode 100644 index 0000000..142cb70 --- /dev/null +++ b/common/worldgen_loader.ts @@ -0,0 +1,37 @@ +// imports mods' worldgen scripts and collects what they register. used by client chunk workers and the game server +import type { WorldgenContext } from "$/common/mod_api/worldgen.ts"; +import type { OreJson } from "$/common/mod_data.ts"; +import type { WorldgenSetup } from "./generation.ts"; +import { ModLoadError } from "./mod_loader.ts"; + +export async function load_worldgen(scripts: { mod: string; url: string }[], ores: OreJson[]): Promise { + const setup: WorldgenSetup = { ores, features: [] }; + + for (const { mod, url } of scripts) { + const ctx: WorldgenContext = { + register_feature(id, generate) { + if (!id.startsWith(`${mod}:`)) { + throw new ModLoadError(mod, `feature ${id} must be in the namespace "${mod}"`); + } + if (setup.features.some((f) => f.id === id)) { + throw new ModLoadError(mod, `feature ${id} is registered twice`); + } + setup.features.push({ id, generate }); + }, + register_terrain(id) { + throw new ModLoadError( + mod, + `can't register terrain ${id}: the base terrain is still built into the engine (phase 3 in MODS.md)`, + ); + }, + }; + + const module = await import(url); + if (typeof module.setup !== "function") { + throw new ModLoadError(mod, "the worldgen script doesn't export a setup function"); + } + await module.setup(ctx); + } + + return setup; +} diff --git a/deno.json b/deno.json index 5fb7c95..1235e7d 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,6 @@ "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts", "new-mod": "deno run --allow-read --allow-write tools/new_mod.ts", "check-mods": "deno run --allow-read --allow-run tools/check_mods.ts", - "export-bworld": "deno run --allow-read --allow-write --allow-run tools/export_bworld_mod.ts", "test": "deno test --allow-read --allow-write --allow-run tests/" }, "compilerOptions": { @@ -28,6 +27,7 @@ "@std/assert": "jsr:@std/assert@^1.0.0", "@std/fs": "jsr:@std/fs@^1.0.23", "@std/http": "jsr:@std/http@^1.0.23", + "@std/path": "jsr:@std/path@^1.0.0", "gl-matrix": "npm:gl-matrix@^3.4.4", "marked": "npm:marked@^17.0.3" } diff --git a/deno.lock b/deno.lock index 5a23245..87c74e7 100644 --- a/deno.lock +++ b/deno.lock @@ -17,6 +17,7 @@ "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@1": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@^1.0.17": "1.1.2", "npm:gl-matrix@^3.4.4": "3.4.4", @@ -54,7 +55,7 @@ "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ "jsr:@std/internal@^1.0.12", - "jsr:@std/path" + "jsr:@std/path@^1.1.4" ] }, "@std/html@1.0.5": { @@ -70,7 +71,7 @@ "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path", + "jsr:@std/path@^1.1.4", "jsr:@std/streams" ] }, @@ -102,6 +103,10 @@ "bin": true } }, + "remote": { + "http://localhost:8768/mods/copper_tools/33af7e7e9e94/client.js": "2f5008856e1e7b13d0fe658aefcdea38dea80b4805e41101c5ff6c2d3d258245", + "http://localhost:8768/mods/copper_tools/33af7e7e9e94/worldgen.js": "a3da997bdfee885433de282338bdba388825868f122c9a8759b2f1bd67120c78" + }, "workspace": { "dependencies": [ "jsr:@gfx/canvas-wasm@~0.4.2", @@ -109,6 +114,7 @@ "jsr:@std/assert@1", "jsr:@std/fs@^1.0.23", "jsr:@std/http@^1.0.23", + "jsr:@std/path@1", "npm:gl-matrix@^3.4.4", "npm:marked@^17.0.3" ] diff --git a/mods/bworld/README.md b/mods/bworld/README.md index 9735da7..a197d74 100644 --- a/mods/bworld/README.md +++ b/mods/bworld/README.md @@ -1,21 +1,13 @@ # bworld -The base game as a mod, following "The base game as a mod" in `MODS.md`. +The base game as a mod, following "The base game as a mod" in `MODS.md`. The game loads its blocks, items, recipes and +textures from here, the same way it loads any other mod. -**The game doesn't load this yet.** Until the mod loader exists, the game still registers everything from -`common/blocks/`, `common/items/`, `server/game/crafting.ts` and `server/game/blocks.ts`. The `blocks/`, `items/` and -`recipes/` folders here are generated from those by: +Still built into the engine, by phase: -```sh -deno task export-bworld -``` +| Phase | What | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2 | Block behavior in `server/game/blocks.ts` (hoeing, chest, furnace) and the watering can's starting water. They become the `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and `bworld:watering_can` components. | +| 3 | Terrain, biomes, trees and ores in `common/generation.ts`. | -Run it after changing any of them. `tests/bworld_mod_test.ts` fails when this folder and the game disagree. - -Still to move here, by phase: - -| Phase | What | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Textures from `assets/sprites/textures/` (the build still reads them from there). | -| 2 | Block behavior from `server/game/blocks.ts`: `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, and the watering can's `bworld:watering_can` item component. | -| 3 | Terrain, biomes, trees and ores from `common/generation.ts`. | +`bworld:stick` has no texture yet, so sticks show as missing. diff --git a/mods/bworld/blocks/planks.json b/mods/bworld/blocks/planks.json index f693ca8..7af6dfc 100644 --- a/mods/bworld/blocks/planks.json +++ b/mods/bworld/blocks/planks.json @@ -7,6 +7,6 @@ "toughness": 3, "tool": "axe" }, - "drops": "bworld:log" + "drops": "bworld:planks" } } diff --git a/assets/sprites/textures/arrow.png b/mods/bworld/textures/arrow.png similarity index 100% rename from assets/sprites/textures/arrow.png rename to mods/bworld/textures/arrow.png diff --git a/assets/sprites/textures/arrow_empty.png b/mods/bworld/textures/arrow_empty.png similarity index 100% rename from assets/sprites/textures/arrow_empty.png rename to mods/bworld/textures/arrow_empty.png diff --git a/assets/sprites/textures/arrow_full.png b/mods/bworld/textures/arrow_full.png similarity index 100% rename from assets/sprites/textures/arrow_full.png rename to mods/bworld/textures/arrow_full.png diff --git a/assets/sprites/textures/axe.png b/mods/bworld/textures/axe.png similarity index 100% rename from assets/sprites/textures/axe.png rename to mods/bworld/textures/axe.png diff --git a/assets/sprites/textures/bomb.png b/mods/bworld/textures/bomb.png similarity index 100% rename from assets/sprites/textures/bomb.png rename to mods/bworld/textures/bomb.png diff --git a/assets/sprites/textures/bow.png b/mods/bworld/textures/bow.png similarity index 100% rename from assets/sprites/textures/bow.png rename to mods/bworld/textures/bow.png diff --git a/assets/sprites/textures/cabbage_seeds.png b/mods/bworld/textures/cabbage_seeds.png similarity index 100% rename from assets/sprites/textures/cabbage_seeds.png rename to mods/bworld/textures/cabbage_seeds.png diff --git a/assets/sprites/textures/carrot.png b/mods/bworld/textures/carrot.png similarity index 100% rename from assets/sprites/textures/carrot.png rename to mods/bworld/textures/carrot.png diff --git a/assets/sprites/textures/carrot_seeds.png b/mods/bworld/textures/carrot_seeds.png similarity index 100% rename from assets/sprites/textures/carrot_seeds.png rename to mods/bworld/textures/carrot_seeds.png diff --git a/assets/sprites/textures/coal.png b/mods/bworld/textures/coal.png similarity index 100% rename from assets/sprites/textures/coal.png rename to mods/bworld/textures/coal.png diff --git a/assets/sprites/textures/copper_ingot.png b/mods/bworld/textures/copper_ingot.png similarity index 100% rename from assets/sprites/textures/copper_ingot.png rename to mods/bworld/textures/copper_ingot.png diff --git a/assets/sprites/textures/dirt.png b/mods/bworld/textures/dirt.png similarity index 100% rename from assets/sprites/textures/dirt.png rename to mods/bworld/textures/dirt.png diff --git a/assets/sprites/textures/empty_bucket.png b/mods/bworld/textures/empty_bucket.png similarity index 100% rename from assets/sprites/textures/empty_bucket.png rename to mods/bworld/textures/empty_bucket.png diff --git a/assets/sprites/textures/fire_empty.png b/mods/bworld/textures/fire_empty.png similarity index 100% rename from assets/sprites/textures/fire_empty.png rename to mods/bworld/textures/fire_empty.png diff --git a/assets/sprites/textures/fire_full.png b/mods/bworld/textures/fire_full.png similarity index 100% rename from assets/sprites/textures/fire_full.png rename to mods/bworld/textures/fire_full.png diff --git a/assets/sprites/textures/furnace.png b/mods/bworld/textures/furnace.png similarity index 100% rename from assets/sprites/textures/furnace.png rename to mods/bworld/textures/furnace.png diff --git a/assets/sprites/textures/glass.png b/mods/bworld/textures/glass.png similarity index 100% rename from assets/sprites/textures/glass.png rename to mods/bworld/textures/glass.png diff --git a/assets/sprites/textures/gold_ingot.png b/mods/bworld/textures/gold_ingot.png similarity index 100% rename from assets/sprites/textures/gold_ingot.png rename to mods/bworld/textures/gold_ingot.png diff --git a/assets/sprites/textures/grass.png b/mods/bworld/textures/grass.png similarity index 100% rename from assets/sprites/textures/grass.png rename to mods/bworld/textures/grass.png diff --git a/assets/sprites/textures/grass_side.png b/mods/bworld/textures/grass_side.png similarity index 100% rename from assets/sprites/textures/grass_side.png rename to mods/bworld/textures/grass_side.png diff --git a/assets/sprites/textures/grass_top.png b/mods/bworld/textures/grass_top.png similarity index 100% rename from assets/sprites/textures/grass_top.png rename to mods/bworld/textures/grass_top.png diff --git a/assets/sprites/textures/hoe.png b/mods/bworld/textures/hoe.png similarity index 100% rename from assets/sprites/textures/hoe.png rename to mods/bworld/textures/hoe.png diff --git a/assets/sprites/textures/hoed_dirt.png b/mods/bworld/textures/hoed_dirt.png similarity index 100% rename from assets/sprites/textures/hoed_dirt.png rename to mods/bworld/textures/hoed_dirt.png diff --git a/assets/sprites/textures/hoed_watered_dirt.png b/mods/bworld/textures/hoed_watered_dirt.png similarity index 100% rename from assets/sprites/textures/hoed_watered_dirt.png rename to mods/bworld/textures/hoed_watered_dirt.png diff --git a/assets/sprites/textures/iron_ingot.png b/mods/bworld/textures/iron_ingot.png similarity index 100% rename from assets/sprites/textures/iron_ingot.png rename to mods/bworld/textures/iron_ingot.png diff --git a/assets/sprites/textures/iron_pickaxe.png b/mods/bworld/textures/iron_pickaxe.png similarity index 100% rename from assets/sprites/textures/iron_pickaxe.png rename to mods/bworld/textures/iron_pickaxe.png diff --git a/assets/sprites/textures/key.png b/mods/bworld/textures/key.png similarity index 100% rename from assets/sprites/textures/key.png rename to mods/bworld/textures/key.png diff --git a/assets/sprites/textures/knife.png b/mods/bworld/textures/knife.png similarity index 100% rename from assets/sprites/textures/knife.png rename to mods/bworld/textures/knife.png diff --git a/assets/sprites/textures/leaves.png b/mods/bworld/textures/leaves.png similarity index 100% rename from assets/sprites/textures/leaves.png rename to mods/bworld/textures/leaves.png diff --git a/assets/sprites/textures/log_side.png b/mods/bworld/textures/log_side.png similarity index 100% rename from assets/sprites/textures/log_side.png rename to mods/bworld/textures/log_side.png diff --git a/assets/sprites/textures/log_top.png b/mods/bworld/textures/log_top.png similarity index 100% rename from assets/sprites/textures/log_top.png rename to mods/bworld/textures/log_top.png diff --git a/assets/sprites/textures/onion.png b/mods/bworld/textures/onion.png similarity index 100% rename from assets/sprites/textures/onion.png rename to mods/bworld/textures/onion.png diff --git a/assets/sprites/textures/pickaxe.png b/mods/bworld/textures/pickaxe.png similarity index 100% rename from assets/sprites/textures/pickaxe.png rename to mods/bworld/textures/pickaxe.png diff --git a/assets/sprites/textures/pitchfork.png b/mods/bworld/textures/pitchfork.png similarity index 100% rename from assets/sprites/textures/pitchfork.png rename to mods/bworld/textures/pitchfork.png diff --git a/assets/sprites/textures/planks.png b/mods/bworld/textures/planks.png similarity index 100% rename from assets/sprites/textures/planks.png rename to mods/bworld/textures/planks.png diff --git a/assets/sprites/textures/potato.png b/mods/bworld/textures/potato.png similarity index 100% rename from assets/sprites/textures/potato.png rename to mods/bworld/textures/potato.png diff --git a/assets/sprites/textures/potato_seeds.png b/mods/bworld/textures/potato_seeds.png similarity index 100% rename from assets/sprites/textures/potato_seeds.png rename to mods/bworld/textures/potato_seeds.png diff --git a/assets/sprites/textures/sand.png b/mods/bworld/textures/sand.png similarity index 100% rename from assets/sprites/textures/sand.png rename to mods/bworld/textures/sand.png diff --git a/assets/sprites/textures/shovel.png b/mods/bworld/textures/shovel.png similarity index 100% rename from assets/sprites/textures/shovel.png rename to mods/bworld/textures/shovel.png diff --git a/assets/sprites/textures/snow.png b/mods/bworld/textures/snow.png similarity index 100% rename from assets/sprites/textures/snow.png rename to mods/bworld/textures/snow.png diff --git a/assets/sprites/textures/stone.png b/mods/bworld/textures/stone.png similarity index 100% rename from assets/sprites/textures/stone.png rename to mods/bworld/textures/stone.png diff --git a/assets/sprites/textures/stone_coal.png b/mods/bworld/textures/stone_coal.png similarity index 100% rename from assets/sprites/textures/stone_coal.png rename to mods/bworld/textures/stone_coal.png diff --git a/assets/sprites/textures/stone_copper.png b/mods/bworld/textures/stone_copper.png similarity index 100% rename from assets/sprites/textures/stone_copper.png rename to mods/bworld/textures/stone_copper.png diff --git a/assets/sprites/textures/stone_gold.png b/mods/bworld/textures/stone_gold.png similarity index 100% rename from assets/sprites/textures/stone_gold.png rename to mods/bworld/textures/stone_gold.png diff --git a/assets/sprites/textures/stone_iron.png b/mods/bworld/textures/stone_iron.png similarity index 100% rename from assets/sprites/textures/stone_iron.png rename to mods/bworld/textures/stone_iron.png diff --git a/assets/sprites/textures/stone_pickaxe.png b/mods/bworld/textures/stone_pickaxe.png similarity index 100% rename from assets/sprites/textures/stone_pickaxe.png rename to mods/bworld/textures/stone_pickaxe.png diff --git a/assets/sprites/textures/stone_tin.png b/mods/bworld/textures/stone_tin.png similarity index 100% rename from assets/sprites/textures/stone_tin.png rename to mods/bworld/textures/stone_tin.png diff --git a/assets/sprites/textures/straw.png b/mods/bworld/textures/straw.png similarity index 100% rename from assets/sprites/textures/straw.png rename to mods/bworld/textures/straw.png diff --git a/assets/sprites/textures/sword.png b/mods/bworld/textures/sword.png similarity index 100% rename from assets/sprites/textures/sword.png rename to mods/bworld/textures/sword.png diff --git a/assets/sprites/textures/tin_ingot.png b/mods/bworld/textures/tin_ingot.png similarity index 100% rename from assets/sprites/textures/tin_ingot.png rename to mods/bworld/textures/tin_ingot.png diff --git a/assets/sprites/textures/tomato.png b/mods/bworld/textures/tomato.png similarity index 100% rename from assets/sprites/textures/tomato.png rename to mods/bworld/textures/tomato.png diff --git a/assets/sprites/textures/tomato_seeds.png b/mods/bworld/textures/tomato_seeds.png similarity index 100% rename from assets/sprites/textures/tomato_seeds.png rename to mods/bworld/textures/tomato_seeds.png diff --git a/assets/sprites/textures/water.png b/mods/bworld/textures/water.png similarity index 100% rename from assets/sprites/textures/water.png rename to mods/bworld/textures/water.png diff --git a/assets/sprites/textures/water_bucket.png b/mods/bworld/textures/water_bucket.png similarity index 100% rename from assets/sprites/textures/water_bucket.png rename to mods/bworld/textures/water_bucket.png diff --git a/assets/sprites/textures/watering_can.png b/mods/bworld/textures/watering_can.png similarity index 100% rename from assets/sprites/textures/watering_can.png rename to mods/bworld/textures/watering_can.png diff --git a/assets/sprites/textures/wheat.png b/mods/bworld/textures/wheat.png similarity index 100% rename from assets/sprites/textures/wheat.png rename to mods/bworld/textures/wheat.png diff --git a/assets/sprites/textures/wheat_seeds.png b/mods/bworld/textures/wheat_seeds.png similarity index 100% rename from assets/sprites/textures/wheat_seeds.png rename to mods/bworld/textures/wheat_seeds.png diff --git a/assets/sprites/textures/wheat_stage1.png b/mods/bworld/textures/wheat_stage1.png similarity index 100% rename from assets/sprites/textures/wheat_stage1.png rename to mods/bworld/textures/wheat_stage1.png diff --git a/assets/sprites/textures/wheat_stage2.png b/mods/bworld/textures/wheat_stage2.png similarity index 100% rename from assets/sprites/textures/wheat_stage2.png rename to mods/bworld/textures/wheat_stage2.png diff --git a/assets/sprites/textures/wheat_stage3.png b/mods/bworld/textures/wheat_stage3.png similarity index 100% rename from assets/sprites/textures/wheat_stage3.png rename to mods/bworld/textures/wheat_stage3.png diff --git a/assets/sprites/textures/wheat_stage4.png b/mods/bworld/textures/wheat_stage4.png similarity index 100% rename from assets/sprites/textures/wheat_stage4.png rename to mods/bworld/textures/wheat_stage4.png diff --git a/assets/sprites/textures/wood_pickaxe.png b/mods/bworld/textures/wood_pickaxe.png similarity index 100% rename from assets/sprites/textures/wood_pickaxe.png rename to mods/bworld/textures/wood_pickaxe.png diff --git a/server/game/blocks.ts b/server/game/blocks.ts index 5c1655b..6777018 100644 --- a/server/game/blocks.ts +++ b/server/game/blocks.ts @@ -1,5 +1,7 @@ import { Container, ItemStack } from "$/common/inventory.ts"; import { ScreenLayout } from "$/common/protocol.ts"; +import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import type { RecipeBook } from "$/common/mod_loader.ts"; import type { GameServer } from "./game_server.ts"; import type { ServerPlayer } from "./player.ts"; import type { Tile } from "./world.ts"; @@ -80,50 +82,6 @@ BLOCK_BEHAVIORS["bworld:chest"] = { // furnace -export interface FurnaceRecipe { - input: string; - output: ItemStack; - cook_time: number; -} - -export const FURNACE_RECIPES: FurnaceRecipe[] = [ - { - input: "bworld:log", - output: new ItemStack("bworld:coal", 1), - cook_time: 100, - }, - { - input: "bworld:coal_ore", - output: new ItemStack("bworld:coal", 1), - cook_time: 100, - }, - { - input: "bworld:iron_ore", - output: new ItemStack("bworld:iron_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:tin_ore", - output: new ItemStack("bworld:tin_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:copper_ore", - output: new ItemStack("bworld:copper_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:gold_ore", - output: new ItemStack("bworld:gold_ingot", 1), - cook_time: 200, - }, -]; - -export const FUEL_VALUES: Record = { - "bworld:coal": 1000, - "bworld:log": 100, -}; - interface FurnaceData { progress: number; progress_max: number; @@ -131,12 +89,17 @@ interface FurnaceData { fuel_max: number; } -function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined { +interface FurnaceRecipe { + output: { id: string; count: number }; + cook_time: number; +} + +function get_recipe(recipes: RecipeBook, input?: ItemStack | undefined): FurnaceRecipe | undefined { if (!input) { return; } - return FURNACE_RECIPES.find((r) => r.input === input.type_id); + return recipes.furnace.get(input.type_id); } function can_craft(container: Container, recipe?: FurnaceRecipe) { @@ -150,27 +113,27 @@ function can_craft(container: Container, recipe?: FurnaceRecipe) { return true; } - if (output.type_id !== recipe.output.type_id) { + if (output.type_id !== recipe.output.id) { return false; } return output.amount < output.max_amount; } -function get_fuel_value(item?: ItemStack | undefined): number { +function get_fuel_value(recipes: RecipeBook, item?: ItemStack | undefined): number { if (!item) { return 0; } - return FUEL_VALUES[item.type_id] ?? 0; + return recipes.fuel.get(item.type_id) ?? 0; } -function has_fuel(container: Container) { - return get_fuel_value(container.get_item(1)) > 0; +function has_fuel(recipes: RecipeBook, container: Container) { + return get_fuel_value(recipes, container.get_item(1)) > 0; } -function consume_fuel(container: Container): number { +function consume_fuel(recipes: RecipeBook, container: Container): number { const fuel = container.get_slot(1)!; - const value = get_fuel_value(fuel.get_item()); + const value = get_fuel_value(recipes, fuel.get_item()); if (fuel.has_item()) { fuel.amount! -= 1; @@ -184,9 +147,9 @@ function craft(container: Container, recipe: FurnaceRecipe) { const output = container.get_item(2); if (!output) { - container.set_item(2, recipe.output.clone()); + container.set_item(2, new ItemStack(recipe.output.id, recipe.output.count)); } else { - output.amount += recipe.output.amount; + output.amount += recipe.output.count; } input.amount -= 1; @@ -241,12 +204,12 @@ BLOCK_BEHAVIORS["bworld:furnace"] = { on_break(_game, tile, player) { give_container_contents(tile, player); }, - on_tick(_game, tile) { + on_tick(game, tile) { const data = tile.data as unknown as FurnaceData; const container = tile.containers.main; const input = container.get_item(0); - const recipe = get_recipe(input); + const recipe = get_recipe(game.recipes, input); // burn fuel if (data.fuel > 0) { @@ -259,8 +222,8 @@ BLOCK_BEHAVIORS["bworld:furnace"] = { } // refuel - if (data.fuel === 0 && has_fuel(container)) { - data.fuel = consume_fuel(container); + if (data.fuel === 0 && has_fuel(game.recipes, container)) { + data.fuel = consume_fuel(game.recipes, container); data.fuel_max = data.fuel; } @@ -276,3 +239,20 @@ BLOCK_BEHAVIORS["bworld:furnace"] = { } }, }; + +// items that still need code, until they're components in mods/bworld (phase 2 in MODS.md) + +export interface WateringCanData { + water: number; + max_water: number; +} + +export function add_base_item_hooks() { + const watering_can = EverythingRegistry.get>("items", "bworld:watering_can"); + if (watering_can) { + watering_can.on_create = (item) => { + item.data = { water: 0, max_water: 32 }; + }; + watering_can.get_lore = (item) => `Water: ${item.data?.water}/${item.data?.max_water}`; + } +} diff --git a/server/game/crafting.ts b/server/game/crafting.ts index 73b60d5..4492432 100644 --- a/server/game/crafting.ts +++ b/server/game/crafting.ts @@ -1,80 +1,8 @@ import { Container, ItemStack } from "$/common/inventory.ts"; import { CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; +import type { GridRecipe } from "$/common/mod_data.ts"; -export interface CraftingRecipe { - width: number; - height: number; - pattern: (string | undefined)[]; - result: { id: string; count: number }; -} - -export const CRAFTING_RECIPES: CraftingRecipe[] = [ - { - width: 3, - height: 3, - pattern: [ - "bworld:planks", - "bworld:planks", - "bworld:planks", - "bworld:planks", - undefined, - "bworld:planks", - "bworld:planks", - "bworld:planks", - "bworld:planks", - ], - result: { id: "bworld:chest", count: 1 }, - }, - { - width: 3, - height: 3, - pattern: [ - "bworld:stone", - "bworld:stone", - "bworld:stone", - "bworld:stone", - undefined, - "bworld:stone", - "bworld:stone", - "bworld:stone", - "bworld:stone", - ], - result: { id: "bworld:furnace", count: 1 }, - }, - { - width: 1, - height: 1, - pattern: [ - "bworld:log", - ], - result: { id: "bworld:planks", count: 2 }, - }, - { - width: 1, - height: 2, - pattern: [ - "bworld:planks", - "bworld:planks", - ], - result: { id: "bworld:stick", count: 2 }, - }, - { - width: 3, - height: 3, - pattern: [ - "bworld:planks", - "bworld:planks", - "bworld:planks", - undefined, - "bworld:stick", - undefined, - undefined, - "bworld:stick", - undefined, - ], - result: { id: "bworld:wood_pickaxe", count: 1 }, - }, -]; +// recipes come from mods, see RecipeBook in common/mod_loader.ts function get_crafting_grid(crafting: Container): (string | undefined)[] { const grid: (string | undefined)[] = []; @@ -84,7 +12,7 @@ function get_crafting_grid(crafting: Container): (string | undefined)[] { return grid; } -function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): boolean { +function matches_recipe(grid: (string | undefined)[], recipe: GridRecipe): boolean { for (let y = 0; y <= 3 - recipe.height; y++) { for (let x = 0; x <= 3 - recipe.width; x++) { let match = true; @@ -115,9 +43,9 @@ function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): b } // puts what the grid makes in the result slot -export function update_crafting_result(crafting: Container) { +export function update_crafting_result(crafting: Container, recipes: GridRecipe[]) { const grid = get_crafting_grid(crafting); - const recipe = CRAFTING_RECIPES.find((recipe) => matches_recipe(grid, recipe)); + const recipe = recipes.find((recipe) => matches_recipe(grid, recipe)); crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined); } diff --git a/server/game/game_server.ts b/server/game/game_server.ts index 94da493..f34005f 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -1,6 +1,3 @@ -import "$/common/blocks/mod.ts"; -import "$/common/items/mod.ts"; - import { AIR, CHUNK_HEIGHT, @@ -23,7 +20,10 @@ import { MAX_NAME_LENGTH, ServerMessage, } from "$/common/protocol.ts"; +import type { ModListing, RecipeBook } from "$/common/mod_loader.ts"; +import type { WorldgenSetup } from "$/common/generation.ts"; import { BLOCK_BEHAVIORS } from "./blocks.ts"; +import { ModRuntime, run_guarded } from "./mod_runtime.ts"; import { consume_recipe_items, update_crafting_result } from "./crafting.ts"; import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts"; import { ServerWorld, Tile } from "./world.ts"; @@ -52,8 +52,20 @@ export interface SavedWorld { z: number; data: Record; containers: Record; + // a mod block's data, what BlockRef.data holds + mod_data?: unknown; }[]; players: Record; + // ctx.storage of every mod, by mod id + mod_storage?: Record>; +} + +// the loaded mods, see load_mods.ts +export interface GameMods { + listings: ModListing[]; + recipes: RecipeBook; + worldgen?: WorldgenSetup; + runtime: ModRuntime; } export class GameServer { @@ -62,13 +74,20 @@ export class GameServer { #players = new Map(); #saved_players: Record = {}; #tick = 0; + #mods: GameMods; + mods: ModRuntime; + recipes: RecipeBook; - constructor(host: GameHost, save: string | undefined, default_seed: string) { + constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) { this.#host = host; + this.#mods = mods; + this.mods = mods.runtime; + this.recipes = mods.recipes; // version 1 saves only had the seed and block changes const saved: Partial | undefined = save ? JSON.parse(save) : undefined; - this.world = new ServerWorld(saved?.seed ?? default_seed); + this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen); + this.mods.storage = saved?.mod_storage ?? {}; this.world.load_changes(saved?.changes ?? []); for (const tile of saved?.tiles ?? []) { const containers: Record = {}; @@ -97,6 +116,7 @@ export class GameServer { this.#saved_players[player.name] = player.save(); this.world.dirty = true; this.#players.delete(conn); + this.mods.after.player_leave.emit({ player: this.mods.player(player) }); this.#broadcast({ type: "player_leave", id: player.id }); this.#broadcast({ type: "chat", text: `${player.name} left` }); console.log(`${player.name} left (${this.#players.size} online)`); @@ -133,20 +153,35 @@ export class GameServer { for (const tile of [...this.world.tiles.values()]) { const behavior = BLOCK_BEHAVIORS[tile.id]; - if (!behavior?.on_tick && !behavior?.on_second) { + const components = this.mods.components_of(tile.id) + .filter(({ component }) => component.on_tick || component.on_second); + if (!behavior?.on_tick && !behavior?.on_second && components.length === 0) { continue; } if (!this.#near_any_player(tile.x, tile.z)) { continue; } - behavior.on_tick?.(this, tile); + behavior?.on_tick?.(this, tile); if (second) { - behavior.on_second?.(this, tile); + behavior?.on_second?.(this, tile); + } + if (components.length > 0 && tile.mod_data !== undefined) { + const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id); + for (const { mod, id, component, params } of components) { + if (component.on_tick) { + run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA)); + } + if (second && component.on_second) { + run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1)); + } + } } // tile data can change on any tick, saving is cheap enough to not track it exactly this.world.dirty = true; } + this.mods.tick(); + for (const player of this.#players.values()) { this.#sync(player); } @@ -169,16 +204,60 @@ export class GameServer { containers: Object.fromEntries( Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]), ), + mod_data: tile.mod_data, })), players: this.#saved_players, + mod_storage: this.mods.storage, }; this.world.dirty = false; return JSON.stringify(saved); } - // used by block behaviors + // used by block behaviors and mods + + get current_tick() { + return this.#tick; + } + + players(): ServerPlayer[] { + return [...this.#players.values()]; + } + + give(player: ServerPlayer, id: string, count: number, data?: unknown) { + // split into stacks so max stack sizes are respected + let left = count; + while (left > 0) { + const stack = new ItemStack(id); + stack.amount = Math.min(left, stack.max_amount); + if (data !== undefined) stack.data = structuredClone(data); + left -= stack.amount; + player.give(stack); + } + } + + send_chat(player: ServerPlayer, text: string) { + this.#send(player, { type: "chat", text }); + } + + teleport(player: ServerPlayer, x: number, y: number, z: number) { + Object.assign(player, { x, y, z }); + this.#send(player, { type: "teleport", x, y, z }); + this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player); + } set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) { + const old_id = this.world.get_block_id(x, y, z); + if (old_id !== AIR_ID) { + const components = this.mods.components_of(old_id).filter(({ component }) => component.on_break); + if (components.length > 0) { + const block = this.mods.block_ref(x, y, z, old_id); + const player_api = player && this.mods.player(player); + for (const { mod, id: component_id, component, params } of components) { + run_guarded(mod, `${component_id} on_break`, () => component.on_break!(block, params, player_api)); + } + } + } + const old_tile = this.world.get_tile(x, y, z); if (old_tile) { BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player); @@ -196,6 +275,15 @@ export class GameServer { if (BLOCK_BEHAVIORS[id]?.create_tile) { this.get_or_create_tile(x, y, z); } + + if (id !== AIR_ID) { + const block = this.mods.block_ref(x, y, z, id); + for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) { + if (component.on_create) { + run_guarded(mod, `${component_id} on_create`, () => component.on_create!(block, params)); + } + } + } } get_or_create_tile(x: number, y: number, z: number): Tile { @@ -230,7 +318,9 @@ export class GameServer { this.#send(player, { type: "welcome", id: player.id, + name: player.name, seed: this.world.seed, + mods: this.#mods.listings, players: [...this.#players.values()].map((p) => p.info()), changes: this.world.all_changes(), spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch }, @@ -241,6 +331,7 @@ export class GameServer { this.#broadcast({ type: "player_join", player: player.info() }, player); this.#broadcast({ type: "chat", text: `${player.name} joined` }); + this.mods.after.player_join.emit({ player: this.mods.player(player) }); console.log(`${player.name} joined (${this.#players.size} online)`); } @@ -290,6 +381,18 @@ export class GameServer { return; } + const event = { + player: this.mods.player(player), + block: this.mods.block_ref(x, y, z, info.id), + item: this.mods.player(player).held_item, + }; + const before = { ...event, cancel: false }; + this.mods.before.block_break.emit(before); + if (before.cancel) { + this.#correct(player, x, y, z); + return; + } + const held = EverythingRegistry.get("items", player.held_item?.type_id ?? ""); const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break); @@ -297,6 +400,7 @@ export class GameServer { if (drops) { player.give(new ItemStack(info.drop_table!)); } + this.mods.after.block_break.emit(event); } #use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) { @@ -310,7 +414,28 @@ export class GameServer { return; } - if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) { + const player_api = this.mods.player(player); + const block = this.mods.block_ref(x, y, z, info.id); + const interact = { player: player_api, block, item: player_api.held_item }; + const before_interact = { ...interact, cancel: false }; + this.mods.before.block_interact.emit(before_interact); + if (before_interact.cancel) { + this.#correct(player, tx, ty, tz); + return; + } + + let handled = BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player) ?? false; + for (const { mod, id: component_id, component, params } of this.mods.components_of(info.id)) { + if (component.on_interact) { + handled = run_guarded(mod, `${component_id} on_interact`, () => + component.on_interact!(block, params, player_api)) === + true || handled; + } + } + if (handled) { + this.mods.after.block_interact.emit(interact); + // the client didn't guess a placement for interactive blocks, but it might have for others + this.#correct(player, tx, ty, tz); return; } @@ -322,8 +447,22 @@ export class GameServer { return; } + const place = { + player: player_api, + block: { id: held.block_id, x: tx, y: ty, z: tz }, + face, + item: player_api.held_item, + }; + const before_place = { ...place, cancel: false }; + this.mods.before.block_place.emit(before_place); + if (before_place.cancel) { + this.#correct(player, tx, ty, tz); + return; + } + this.set_block(tx, ty, tz, held.block_id, player); held_slot.amount = held_slot.amount! - 1; + this.mods.after.block_place.emit(place); } #click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) { @@ -350,7 +489,7 @@ export class GameServer { } if (key === "crafting") { - update_crafting_result(container); + update_crafting_result(container, this.recipes.shaped); } } @@ -366,8 +505,15 @@ export class GameServer { this.#command(player, text); return; } - console.log(`<${player.name}> ${text}`); - this.#broadcast({ type: "chat", from: player.name, text }); + const before = { player: this.mods.player(player), message: text, cancel: false }; + this.mods.before.chat_send.emit(before); + const message = String(before.message).slice(0, MAX_CHAT_LENGTH); + if (before.cancel || message.length === 0) { + return; + } + console.log(`<${player.name}> ${message}`); + this.#broadcast({ type: "chat", from: player.name, text: message }); + this.mods.after.chat_send.emit({ player: before.player, message }); } #command(player: ServerPlayer, text: string) { @@ -391,14 +537,12 @@ export class GameServer { this.#send(player, { type: "chat", text: `Count must be between 1 and ${MAX_GIVE}` }); return; } - // split into stacks so max stack sizes are respected - let left = amount; - while (left > 0) { - const stack = new ItemStack(item_id); - stack.amount = Math.min(left, stack.max_amount); - left -= stack.amount; - player.give(stack); - } + this.give(player, item_id, amount); + return; + } + const mod_command = this.mods.commands.get(command); + if (mod_command) { + run_guarded(mod_command.mod, `/${command}`, () => mod_command.command.run(args, this.mods.player(player))); return; } this.#send(player, { type: "chat", text: `Unknown command /${command}` }); @@ -417,7 +561,7 @@ export class GameServer { player.crafting.set_item(i, undefined); } } - update_crafting_result(player.crafting); + update_crafting_result(player.crafting, this.recipes.shaped); if (player.cursor.item) { player.give(player.cursor.item); player.cursor.item = undefined; diff --git a/server/game/host_protocol.ts b/server/game/host_protocol.ts index 24f7a40..a226496 100644 --- a/server/game/host_protocol.ts +++ b/server/game/host_protocol.ts @@ -1,7 +1,15 @@ +import type { ModData, ModListing } from "$/common/mod_loader.ts"; + // messages between server/main.ts (the host) and the game server worker export type HostToGame = - | { type: "init"; save: string | undefined; default_seed: string } + | { + type: "init"; + save: string | undefined; + default_seed: string; + // in load order, with the scripts' code since the worker can't read files + mods: { listing: ModListing; data: ModData; server_code?: string; worldgen_code?: string }[]; + } | { type: "connect"; conn: number } | { type: "message"; conn: number; data: string } | { type: "disconnect"; conn: number } @@ -10,6 +18,7 @@ export type HostToGame = export type GameToHost = | { type: "ready"; seed: string } + | { type: "failed"; error: string } | { type: "send"; conn: number; data: string } | { type: "close"; conn: number } | { type: "save"; data: string; final: boolean }; diff --git a/server/game/load_mods.ts b/server/game/load_mods.ts new file mode 100644 index 0000000..3db2cef --- /dev/null +++ b/server/game/load_mods.ts @@ -0,0 +1,59 @@ +// starts a game server with its mods: registers their data, then imports worldgen and server scripts. +// the worker loads built mods, tests can load mods straight from their source folders +import { register_mod_data } from "$/common/mod_loader.ts"; +import type { ModData, ModListing } from "$/common/mod_loader.ts"; +import { load_worldgen } from "$/common/worldgen_loader.ts"; +import { add_base_item_hooks } from "./blocks.ts"; +import { GameHost, GameServer } from "./game_server.ts"; +import { ModRuntime } from "./mod_runtime.ts"; + +export interface ServerModSource { + listing: ModListing; + data: ModData; + // importable urls of the scripts: data: urls in the worker, file urls in tests + server_url?: string; + worldgen_url?: string; +} + +export async function start_game( + host: GameHost, + save: string | undefined, + default_seed: string, + mods: ServerModSource[], +): Promise { + const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data }))); + add_base_item_hooks(); + + const worldgen_scripts = mods.flatMap((mod) => + mod.worldgen_url ? [{ mod: mod.listing.id, url: mod.worldgen_url }] : [] + ); + const worldgen = await load_worldgen(worldgen_scripts, recipes.ores); + + const runtime = new ModRuntime(); + const game = new GameServer(host, save, default_seed, { + listings: mods.map((mod) => mod.listing), + recipes, + worldgen, + runtime, + }); + + await runtime.load_scripts( + game, + mods.flatMap((mod) => + mod.server_url ? [{ mod: mod.listing.id, version: mod.listing.version, url: mod.server_url }] : [] + ), + ); + runtime.finish_setup(); + runtime.after.server_start.emit({}); + + return game; +} + +export function data_url(code: string) { + const bytes = new TextEncoder().encode(code); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return `data:application/javascript;base64,${btoa(binary)}`; +} diff --git a/server/game/mod_runtime.ts b/server/game/mod_runtime.ts new file mode 100644 index 0000000..a583110 --- /dev/null +++ b/server/game/mod_runtime.ts @@ -0,0 +1,429 @@ +// runs mods' server scripts: builds each one's ServerContext and keeps what they register. +// see "Server scripts" in MODS.md. parts not built yet throw when used, saying so +import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { ItemStack as EngineItemStack } from "$/common/inventory.ts"; +import type { + BlockComponent, + BlockRef, + Command, + Container, + EventSignal, + ItemComponent, + ItemStack, + Player, + ServerAfterEvents, + ServerBeforeEvents, + ServerContext, +} from "$/common/mod_api/server.ts"; +import { ModLoadError } from "$/common/mod_loader.ts"; +import { AIR_ID } from "$/common/protocol.ts"; +import type { GameServer } from "./game_server.ts"; +import type { ServerPlayer } from "./player.ts"; + +// a list of handlers that can't break each other: one throwing is logged under its mod and the rest still run +export class Signal { + #handlers: { mod: string; handler: (event: T) => void }[] = []; + + for_mod(mod: string): EventSignal { + return { + subscribe: (handler) => { + const entry = { mod, handler }; + this.#handlers.push(entry); + return () => { + this.#handlers = this.#handlers.filter((h) => h !== entry); + }; + }, + }; + } + + emit(event: T) { + for (const { mod, handler } of [...this.#handlers]) { + run_guarded(mod, "an event handler", () => handler(event)); + } + } + + get empty() { + return this.#handlers.length === 0; + } +} + +export function run_guarded(mod: string, what: string, fn: () => T): T | undefined { + try { + return fn(); + } catch (e) { + console.error(`[${mod}] ${what} threw:`, e); + return undefined; + } +} + +type Before = { + [K in keyof ServerBeforeEvents]: ServerBeforeEvents[K] extends EventSignal ? Signal : never; +}; +type After = { [K in keyof ServerAfterEvents]: ServerAfterEvents[K] extends EventSignal ? Signal : never }; + +interface Timer { + mod: string; + fn: () => void; + at: number; + every?: number; +} + +export class ModRuntime { + block_components = new Map(); + item_components = new Map(); + commands = new Map(); + + before: Before = { + block_break: new Signal(), + block_place: new Signal(), + block_interact: new Signal(), + chat_send: new Signal(), + }; + after: After = { + block_break: new Signal(), + block_place: new Signal(), + block_interact: new Signal(), + chat_send: new Signal(), + player_join: new Signal(), + player_leave: new Signal(), + server_start: new Signal(), + tick: new Signal(), + }; + + // per mod key value storage, saved with the world + storage: Record> = {}; + + #timers = new Map(); + #next_timer = 1; + #setting_up = true; + #players = new WeakMap(); + game!: GameServer; + + // imports each server script and calls its setup, in load order + async load_scripts(game: GameServer, scripts: { mod: string; version: string; url: string }[]) { + this.game = game; + for (const { mod, version, url } of scripts) { + const module = await import(url); + if (typeof module.setup !== "function") { + throw new ModLoadError(mod, "the server script doesn't export a setup function"); + } + await module.setup(this.#context(mod, version)); + } + } + + // after every setup ran: registration closes and every component blocks and items use must exist + finish_setup() { + this.#setting_up = false; + + for (const [id, block] of EverythingRegistry.entries<{ components?: Record }>("blocks")) { + for (const component of Object.keys(block.components ?? {})) { + if (!this.block_components.has(component)) { + throw new ModLoadError( + id.split(":")[0], + `block ${id} uses component ${component}, which nothing registered`, + ); + } + } + } + + for (const [id, item] of EverythingRegistry.entries("items")) { + const components = Object.entries(item.components ?? {}); + if (components.length === 0) continue; + for (const [component_id] of components) { + if (!this.item_components.has(component_id)) { + throw new ModLoadError( + id.split(":")[0], + `item ${id} uses component ${component_id}, which nothing registered`, + ); + } + } + // items are created all over the engine, hook their creation once here + const previous = item.on_create; + item.on_create = (stack) => { + previous?.(stack); + for (const [component_id, params] of components) { + const { mod, component } = this.item_components.get(component_id)!; + if (component.on_create) { + run_guarded( + mod, + `${component_id} on_create`, + () => component.on_create!(item_api(stack), params), + ); + } + } + }; + } + } + + // components on a block, with their params from its json + components_of(block_id: string): { mod: string; id: string; component: BlockComponent; params: unknown }[] { + const block = EverythingRegistry.get<{ components?: Record }>("blocks", block_id); + return Object.entries(block?.components ?? {}).map(([id, params]) => ({ + ...this.block_components.get(id)!, + id, + params, + })); + } + + tick() { + const now = this.game.current_tick; + for (const [handle, timer] of [...this.#timers]) { + if (timer.at > now) continue; + if (timer.every) { + timer.at = now + timer.every; + } else { + this.#timers.delete(handle); + } + run_guarded(timer.mod, "a timer", timer.fn); + } + if (!this.after.tick.empty) { + this.after.tick.emit({ dt: 1 / 20 }); + } + } + + player(player: ServerPlayer): Player { + let api = this.#players.get(player); + if (!api) { + api = player_api(this.game, player); + this.#players.set(player, api); + } + return api; + } + + block_ref(x: number, y: number, z: number, id: string): BlockRef { + const game = this.game; + return { + id, + x, + y, + z, + get data() { + return game.world.get_tile(x, y, z)?.mod_data; + }, + set data(value) { + game.get_or_create_tile(x, y, z).mod_data = value; + game.world.dirty = true; + }, + }; + } + + #context(mod: string, version: string): ServerContext { + const setup_only = (what: string) => { + if (!this.#setting_up) { + throw new Error(`[${mod}] ${what} can only be registered during setup`); + } + }; + const own = (id: string, what: string) => { + if (!id.startsWith(`${mod}:`)) { + throw new ModLoadError(mod, `${what} ${id} must be in the namespace "${mod}"`); + } + }; + const game = () => this.game; + + const ctx: ServerContext = { + mod: { id: mod, version }, + components: { + register_block: (id, component) => { + setup_only("components"); + own(id, "component"); + if (this.block_components.has(id)) { + throw new ModLoadError(mod, `component ${id} is registered twice`); + } + this.block_components.set(id, { mod, component: component as BlockComponent }); + }, + register_item: (id, component) => { + setup_only("components"); + own(id, "component"); + if (this.item_components.has(id)) { + throw new ModLoadError(mod, `component ${id} is registered twice`); + } + this.item_components.set(id, { mod, component: component as ItemComponent }); + }, + }, + commands: { + register: (name, command) => { + setup_only("commands"); + if (!/^[a-z0-9_]+$/.test(name)) { + throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`); + } + if (name === "give") throw new ModLoadError(mod, `/give belongs to the base game`); + const existing = this.commands.get(name); + if (existing) { + throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`); + } + const entry = { mod, name, command }; + this.commands.set(name, entry); + this.commands.set(`${mod}:${name}`, entry); + }, + }, + events: { + before: map_signals(this.before, mod), + after: map_signals(this.after, mod), + }, + system: { + run_timeout: (fn, ticks) => this.#add_timer(mod, fn, ticks), + run_interval: (fn, ticks) => this.#add_timer(mod, fn, ticks, Math.max(1, ticks)), + clear_run: (handle) => void this.#timers.delete(handle), + get current_tick() { + return game().current_tick; + }, + }, + world: { + get_block: (x, y, z) => game().world.get_block_id(x, y, z), + set_block: (x, y, z, id) => { + if (id !== AIR_ID && !EverythingRegistry.get("blocks", id)) throw new Error(`unknown block ${id}`); + game().set_block(x, y, z, id); + return true; + }, + get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"), + set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"), + get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never, + is_loaded: () => true, + get seed() { + return game().world.seed; + }, + }, + players: { + all: () => game().players().map((p) => this.player(p)), + get: (id) => { + const p = game().players().find((p) => p.id === id); + return p && this.player(p); + }, + by_name: (name) => { + const p = game().players().find((p) => p.name === name); + return p && this.player(p); + }, + }, + recipes: { + furnace_result: (input) => { + const recipe = game().recipes.furnace.get(input); + return recipe && { output: { ...recipe.output }, cook_time: recipe.cook_time }; + }, + fuel_value: (item) => game().recipes.fuel.get(item) ?? 0, + is_fuel: (item) => game().recipes.fuel.has(item), + is_smeltable: (item) => game().recipes.furnace.has(item), + }, + containers: not_yet_object(mod, "containers", "step 7 in MODS.md"), + ui: not_yet_object(mod, "ui", "step 7 in MODS.md"), + net: not_yet_object(mod, "net", "step 8 in MODS.md"), + storage: { + get: (key) => structuredClone(this.storage[mod]?.[key]) as never, + set: (key, value) => { + (this.storage[mod] ??= {})[key] = structuredClone(value); + game().world.dirty = true; + }, + delete: (key) => { + delete this.storage[mod]?.[key]; + game().world.dirty = true; + }, + }, + log: (...args) => console.log(`[${mod}]`, ...args), + }; + return ctx; + } + + #add_timer(mod: string, fn: () => void, ticks: number, every?: number) { + const handle = this.#next_timer++; + this.#timers.set(handle, { mod, fn, at: this.game.current_tick + Math.max(0, ticks), every }); + return handle; + } +} + +// each event's signal, as seen by one mod so errors say which mod threw +function map_signals( + signals: T, + mod: string, +): { [K in keyof T]: ReturnType } { + return Object.fromEntries(Object.entries(signals).map(([name, signal]) => [name, signal.for_mod(mod)])) as { + [K in keyof T]: ReturnType; + }; +} + +function not_yet(mod: string, name: string, why: string) { + return () => { + throw new Error(`[${mod}] ctx.${name} isn't implemented yet: ${why}`); + }; +} + +// every method on it throws, saying what isn't built yet +function not_yet_object(mod: string, name: string, where: string): T { + return new Proxy({}, { + get: (_, prop) => not_yet(mod, `${name}.${String(prop)}`, where), + }) as T; +} + +// the engine's item stacks as mods see them: { id, count, data } +export function item_api(stack: EngineItemStack): ItemStack { + return { + get id() { + return stack.type_id; + }, + get count() { + return stack.amount; + }, + set count(value) { + stack.amount = value; + }, + get data() { + return stack.data; + }, + set data(value) { + stack.data = value; + }, + }; +} + +function new_stack(item: ItemStack): EngineItemStack { + if (!EverythingRegistry.get("items", item.id)) throw new Error(`unknown item ${item.id}`); + const stack = new EngineItemStack(item.id, item.count); + if (item.data !== undefined) stack.data = structuredClone(item.data); + return stack; +} + +function player_api(game: GameServer, player: ServerPlayer): Player { + const inventory: Container = { + id: `player:${player.id}`, + size: player.inventory.size, + get: (slot) => { + const stack = player.inventory.get_item(slot); + return stack && item_api(stack); + }, + set: (slot, item) => player.inventory.set_item(slot, item ? new_stack(item) : undefined), + add: (item) => { + const stack = new_stack(item); + const left = player.inventory.add_item(stack); + return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined; + }, + on_change: not_yet("player", "inventory.on_change", "containers are step 7 in MODS.md"), + }; + + return { + get id() { + return player.id; + }, + get name() { + return player.name; + }, + get position() { + return { x: player.x, y: player.y, z: player.z }; + }, + inventory, + get selected_slot() { + return player.selected_slot; + }, + get held_item() { + const stack = player.held_item; + return stack && item_api(stack); + }, + give_item(id, count = 1, data) { + if (!EverythingRegistry.get("items", id)) throw new Error(`unknown item ${id}`); + game.give(player, id, count, data); + }, + send_message(text) { + game.send_chat(player, String(text)); + }, + teleport(x, y, z) { + game.teleport(player, x, y, z); + }, + }; +} diff --git a/server/game/worker.ts b/server/game/worker.ts index edc3fca..13eebe5 100644 --- a/server/game/worker.ts +++ b/server/game/worker.ts @@ -1,41 +1,30 @@ /// // runs the game in a worker without any permissions, the host does files and networking -import { GameServer, TICK_MS } from "./game_server.ts"; +import type { GameServer } from "./game_server.ts"; +import { TICK_MS } from "./game_server.ts"; import { GameToHost, HostToGame } from "./host_protocol.ts"; +import { data_url, start_game } from "./load_mods.ts"; const SAVE_INTERVAL_MS = 30_000; let game: GameServer | undefined; +let starting: Promise | undefined; function post(message: GameToHost) { self.postMessage(message); } -self.onmessage = (event: MessageEvent) => { +self.onmessage = async (event: MessageEvent) => { const message = event.data; if (message.type === "init") { - game = new GameServer( - { - send: (conn, data) => post({ type: "send", conn, data }), - close: (conn) => post({ type: "close", conn }), - }, - message.save, - message.default_seed, - ); - - setInterval(() => game!.tick(), TICK_MS); - setInterval(() => { - if (game!.world.dirty) { - post({ type: "save", data: game!.save(), final: false }); - } - }, SAVE_INTERVAL_MS); - - post({ type: "ready", seed: game.world.seed }); + starting = init(message); return; } + // players can connect while mods are still loading + await starting; if (!game) { return; } @@ -55,3 +44,35 @@ self.onmessage = (event: MessageEvent) => { break; } }; + +async function init(message: Extract) { + try { + game = await start_game( + { + send: (conn, data) => post({ type: "send", conn, data }), + close: (conn) => post({ type: "close", conn }), + }, + message.save, + message.default_seed, + message.mods.map((mod) => ({ + listing: mod.listing, + data: mod.data, + // the worker has no file access, so the host sends the code and it's imported from memory + server_url: mod.server_code ? data_url(mod.server_code) : undefined, + worldgen_url: mod.worldgen_code ? data_url(mod.worldgen_code) : undefined, + })), + ); + } catch (e) { + post({ type: "failed", error: e instanceof Error ? e.message : String(e) }); + return; + } + + setInterval(() => game!.tick(), TICK_MS); + setInterval(() => { + if (game!.world.dirty) { + post({ type: "save", data: game!.save(), final: false }); + } + }, SAVE_INTERVAL_MS); + + post({ type: "ready", seed: game.world.seed }); +} diff --git a/server/game/world.ts b/server/game/world.ts index 7913203..474daa4 100644 --- a/server/game/world.ts +++ b/server/game/world.ts @@ -1,6 +1,6 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { generate_raw_chunk, RawChunk } from "$/common/generation.ts"; +import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts"; import { Container } from "$/common/inventory.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { chunk_key } from "$/common/utils.ts"; @@ -18,6 +18,8 @@ export interface Tile { z: number; data: Record; containers: Record; + // a mod block's data, what BlockRef.data holds + mod_data?: unknown; } export function position_key(x: number, y: number, z: number) { @@ -28,6 +30,7 @@ export function position_key(x: number, y: number, z: number) { export class ServerWorld { readonly seed: string; readonly block_ids: Record = {}; + readonly worldgen: WorldgenSetup | undefined; // what generation made, and the final blocks with neighbors' leaves and player changes applied #raw = new LruMap(RAW_CACHE_SIZE); @@ -42,8 +45,9 @@ export class ServerWorld { on_block_change?: (x: number, y: number, z: number, id: string) => void; - constructor(seed: string) { + constructor(seed: string, worldgen?: WorldgenSetup) { this.seed = seed; + this.worldgen = worldgen; EverythingRegistry.get_registry("blocks").forEach((block, nid) => { this.block_ids[block.id] = nid; }); @@ -131,7 +135,7 @@ export class ServerWorld { const key = chunk_key(chunk_x, chunk_z); let raw = this.#raw.get(key); if (!raw) { - raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids); + raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids, this.worldgen); this.#raw.set(key, raw); } return raw; diff --git a/server/main.ts b/server/main.ts index 55aadbd..f4b6e47 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,9 +1,11 @@ import { serveDir } from "@std/http/file-server"; import { GameToHost, HostToGame } from "./game/host_protocol.ts"; +import type { ServerModIndex } from "../build.ts"; const PORT = Number(Deno.env.get("PORT") ?? 8000); const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; -const STATIC_ROOT = "build"; +const STATIC_ROOT = Deno.env.get("BUILD_DIR") ?? "build"; +const SERVER_MODS_DIR = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods"; const MAX_MESSAGE_SIZE = 4096; const SHUTDOWN_TIMEOUT_MS = 5000; @@ -43,6 +45,10 @@ game.onmessage = (event: MessageEvent) => { case "ready": console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`); break; + case "failed": + console.error(`Couldn't start the game: ${message.error}`); + Deno.exit(1); + break; case "send": { const socket = sockets.get(message.conn); if (socket?.readyState === WebSocket.OPEN) { @@ -63,13 +69,32 @@ game.onmessage = (event: MessageEvent) => { } }; +// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods +function read_mods(): Extract["mods"] { + let index: ServerModIndex; + try { + index = JSON.parse(Deno.readTextFileSync(`${SERVER_MODS_DIR}/index.json`)); + } catch { + console.error(`No ${SERVER_MODS_DIR}/index.json, run deno task build first (it also reports mod errors)`); + Deno.exit(1); + } + return index.mods.map(({ listing, server }) => ({ + listing, + data: JSON.parse(Deno.readTextFileSync(`${STATIC_ROOT}/${listing.data}`)), + server_code: server ? Deno.readTextFileSync(server) : undefined, + worldgen_code: listing.worldgen ? Deno.readTextFileSync(`${STATIC_ROOT}/${listing.worldgen}`) : undefined, + })); +} + game.onerror = (event) => { console.error("Game server crashed:", event.message); Deno.exit(1); }; +const mods = read_mods(); +console.log(`Mods: ${mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`); const save = read_world(); -post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() }); +post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID(), mods }); function handle_socket(socket: WebSocket) { const conn = next_conn++; diff --git a/tests/bworld_mod_test.ts b/tests/bworld_mod_test.ts deleted file mode 100644 index 3163500..0000000 --- a/tests/bworld_mod_test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { - block_from_json, - block_to_json, - grid_recipe_from_json, - grid_recipe_to_json, - item_from_json, - item_to_json, -} from "$/common/mod_data.ts"; -import { CRAFTING_RECIPES } from "$/server/game/crafting.ts"; -import { bworld_data_files } from "$/tools/export_bworld_mod.ts"; - -// mods/bworld is generated from the typescript definitions until the loader exists, keep them in sync -Deno.test("mods/bworld matches what the game registers", () => { - const expected = new Map(bworld_data_files().map(({ path, content }) => [path, content])); - const actual = new Map(); - for (const folder of ["blocks", "items", "recipes"]) { - for (const file of Deno.readDirSync(`mods/bworld/${folder}`)) { - actual.set( - `${folder}/${file.name}`, - JSON.parse(Deno.readTextFileSync(`mods/bworld/${folder}/${file.name}`)), - ); - } - } - assertEquals( - [...actual.keys()].sort(), - [...expected.keys()].sort(), - "files differ, run deno task export-bworld", - ); - for (const [path, content] of expected) { - assertEquals(actual.get(path), content, `${path} is out of date, run deno task export-bworld`); - } -}); - -// what the loader will do with the json has to give back exactly what the game uses now -Deno.test("blocks survive going to json and back", () => { - const items = new Map(EverythingRegistry.entries("items")); - for (const [id, block] of EverythingRegistry.entries("blocks")) { - const has_item = items.get(id)?.block_id === id; - const back = block_from_json(block_to_json(block, has_item)); - assertEquals(back.block, without_undefined(block), id); - assertEquals(back.has_item, has_item, id); - } -}); - -Deno.test("items survive going to json and back", () => { - for (const [id, item] of EverythingRegistry.entries("items")) { - if (item.block_id !== undefined) continue; - const { on_create: _on_create, get_lore: _get_lore, ...data } = item; - assertEquals(item_from_json(item_to_json(id, item)), without_undefined(data), id); - } -}); - -Deno.test("crafting recipes survive going to json and back", () => { - for (const recipe of CRAFTING_RECIPES) { - const json = grid_recipe_to_json(recipe); - if (json.type !== "shaped") throw new Error("expected a shaped recipe"); - assertEquals(grid_recipe_from_json(json), recipe, recipe.result.id); - } -}); - -function without_undefined(value: T): T { - return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T; -} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..0bda5cd --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,65 @@ +import { resolve, toFileUrl } from "@std/path"; +import { EverythingRegistry } from "$/common/everything_registry.ts"; +import { load_and_check, load_order } from "$/tools/check_mods.ts"; +import type { ServerModSource } from "$/server/game/load_mods.ts"; +import { GameServer } from "$/server/game/game_server.ts"; +import { start_game } from "$/server/game/load_mods.ts"; + +// mods straight from their source folders, skipping the build: scripts are imported as typescript +export function mod_sources(mods_dir: string): ServerModSource[] { + const mods = load_and_check(mods_dir); + const errors = mods.flatMap((mod) => mod.report.errors.map((e) => `${mod.id}: ${e}`)); + if (errors.length) throw new Error(errors.join("\n")); + return load_order(mods).map((mod) => { + const scripts = (mod.manifest?.scripts ?? {}) as Record; + const url = (path?: string) => path ? toFileUrl(resolve(mod.dir, path)).href : undefined; + return { + listing: { + id: mod.id, + name: String(mod.manifest?.name), + version: String(mod.manifest?.version), + hash: "source", + data: `mods/${mod.id}/source/data.json`, + }, + data: { + blocks: mod.blocks.map((b) => b.json), + items: mod.items.map((i) => i.json), + recipes: mod.recipes.map((r) => r.json), + ores: mod.ores.map((o) => o.json), + }, + server_url: url(scripts.server), + worldgen_url: url(scripts.worldgen), + }; + }); +} + +// a game with an in memory host that remembers what it sent to each connection +export async function test_game(mods_dir: string, save?: string, seed = "test-seed") { + EverythingRegistry.clear(); + const outbox = new Map(); + const host = { + send: (conn: number, data: string) => { + if (!outbox.has(conn)) outbox.set(conn, []); + outbox.get(conn)!.push(JSON.parse(data)); + }, + close() {}, + }; + const game: GameServer = await start_game(host, save, seed, mod_sources(mods_dir)); + // deno-lint-ignore no-explicit-any + const take = (conn: number): any[] => { + const messages = outbox.get(conn) ?? []; + outbox.set(conn, []); + return messages; + }; + const send = (conn: number, message: unknown) => game.on_message(conn, JSON.stringify(message)); + return { game, take, send }; +} + +export function copy_dir(from: string, to: string) { + if (Deno.statSync(from).isDirectory) { + Deno.mkdirSync(to, { recursive: true }); + for (const entry of Deno.readDirSync(from)) copy_dir(`${from}/${entry.name}`, `${to}/${entry.name}`); + } else { + Deno.copyFileSync(from, to); + } +} diff --git a/tests/mod_loading_test.ts b/tests/mod_loading_test.ts new file mode 100644 index 0000000..78f2aba --- /dev/null +++ b/tests/mod_loading_test.ts @@ -0,0 +1,213 @@ +import { assert, assertEquals } from "@std/assert"; +import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts"; +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts"; +import { generate_raw_chunk } from "$/common/generation.ts"; +import { load_worldgen } from "$/common/worldgen_loader.ts"; +import { create_mod } from "$/tools/new_mod.ts"; +import { copy_dir, test_game } from "./helpers.ts"; + +// mods/bworld plus a mod made from the template, in a temp folder +function mods_with_template() { + const dir = Deno.makeTempDirSync({ prefix: "bworld_loading_" }); + copy_dir("mods/bworld", `${dir}/bworld`); + create_mod("copper_tools", "Copper Tools", dir); + return dir; +} + +// deno-lint-ignore no-explicit-any +type Msg = any; +const inventory_of = (messages: Msg[]) => + messages.filter((m) => m.type === "container" && m.container === "inventory").at(-1)?.items ?? []; + +Deno.test("the base game loads from mods/bworld", async () => { + const { game } = await test_game("mods"); + assertEquals(EverythingRegistry.entries("blocks").length, 18); + // 11 items plus the item forms of the 16 blocks that have one + assertEquals(EverythingRegistry.entries("items").length, 27); + assertEquals(game.recipes.shaped.length, 5); + assertEquals(game.recipes.furnace.size, 6); + assertEquals(game.recipes.fuel.size, 2); + assertEquals(EverythingRegistry.get("blocks", "bworld:water")?.replaceable, true); + // still has its code until it's a component + assert(EverythingRegistry.get("items", "bworld:watering_can")?.on_create); +}); + +Deno.test("mods/bworld json survives going to the registry and back", async () => { + await test_game("mods"); + for (const file of Deno.readDirSync("mods/bworld/blocks")) { + const { block: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/blocks/${file.name}`)); + const { block, has_item } = block_from_json(json); + assertEquals(block_to_json(block, has_item), json, file.name); + } + for (const file of Deno.readDirSync("mods/bworld/items")) { + const { item: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/items/${file.name}`)); + assertEquals(item_to_json(json.id, item_from_json(json)), json, file.name); + } +}); + +Deno.test("a mod made from the template loads and runs", async () => { + const dir = mods_with_template(); + const { game, take, send } = await test_game(dir); + + // data + assert(EverythingRegistry.get("blocks", "copper_tools:example_block")); + assert(EverythingRegistry.get("items", "copper_tools:example_item")); + assertEquals( + EverythingRegistry.get("items", "copper_tools:example_item")?.lore, + "Four of these make an example block.", + ); + + game.on_connect(1); + send(1, { type: "hello", name: "alice" }); + const welcome = take(1)[0]; + assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]); + + // the command, by name and by namespaced name + send(1, { type: "chat", text: "/hello" }); + send(1, { type: "chat", text: "/copper_tools:hello" }); + const hellos = take(1).filter((m) => m.type === "chat" && m.text === "Hello alice!"); + assertEquals(hellos.length, 2); + + // place the example block and right click it: the component answers and nothing gets placed on top + send(1, { type: "chat", text: "/give copper_tools:example_block" }); + const inventory = inventory_of(take(1)); + send(1, { type: "select_slot", slot: inventory.findIndex((i: Msg) => i?.id === "copper_tools:example_block") }); + let y = CHUNK_HEIGHT - 1; + while (game.world.get_block_nid(1, y, 1) === AIR) y--; + send(1, { type: "move", x: 2.5, y: y + 1, z: 2.5, yaw: 0, pitch: 0 }); + send(1, { type: "use_block", x: 1, y, z: 1, face: "top" }); + assertEquals(game.world.get_block_id(1, y + 1, 1), "copper_tools:example_block"); + take(1); + send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" }); + const messages = take(1); + assert(messages.some((m) => m.type === "chat" && m.text === "You found the example block!")); + assertEquals(game.world.get_block_id(1, y + 2, 1), "bworld:air"); + + // the shaped recipe: 4 example items in a square + send(1, { type: "chat", text: "/give copper_tools:example_item 4" }); + const items_slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "copper_tools:example_item"); + send(1, { type: "click", container: "inventory", index: items_slot, button: 0 }); + for (const slot of [0, 1, 3, 4]) send(1, { type: "click", container: "crafting", index: slot, button: 2 }); + const crafting = take(1).filter((m) => m.container === "crafting").at(-1).items; + assertEquals(crafting[9]?.id, "copper_tools:example_block"); + + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("the template's worldgen feature places boulders, the same way every time", async () => { + const dir = mods_with_template(); + const { game } = await test_game(dir, undefined, "boulder-seed"); + const worldgen = game.world.worldgen!; + assertEquals(worldgen.features.map((f) => f.id), ["copper_tools:boulders"]); + + // compare against generation without mods: boulders are extra stone on top of the surface + let boulders = 0; + for (let cx = 0; cx < 20; cx++) { + const plain = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids); + const modded = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen); + const differences = plain.blocks.filter((b, i) => b !== modded.blocks[i]).length; + if (differences > 0) boulders++; + assert(differences <= 1, `chunk ${cx} changed ${differences} blocks`); + } + assert(boulders >= 1 && boulders <= 8, `${boulders} of 20 chunks got a boulder, expected about 2`); + + // loading the script again (like a client does) gives the same terrain + const again = await load_worldgen( + [{ + mod: "copper_tools", + url: new URL(`file://${Deno.realPathSync(dir)}/copper_tools/scripts/worldgen.ts`).href, + }], + [], + ); + for (let cx = 0; cx < 20; cx++) { + assertEquals( + generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, again).blocks, + generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen).blocks, + ); + } + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("mod ores generate into the block they replace", async () => { + const dir = mods_with_template(); + Deno.mkdirSync(`${dir}/copper_tools/worldgen`); + Deno.writeTextFileSync( + `${dir}/copper_tools/worldgen/ores.json`, + JSON.stringify({ + format_version: 1, + ores: [{ + id: "copper_tools:example_block", + replaces: "bworld:stone", + min_y: 0, + max_y: 60, + scale: 0.1, + threshold: 0.5, + }], + }), + ); + const { game } = await test_game(dir); + const ore = game.world.block_ids["copper_tools:example_block"]; + let found = 0; + for (let cx = 0; cx < 4; cx++) { + const blocks = generate_raw_chunk(cx, 0, "test-seed", game.world.block_ids, game.world.worldgen).blocks; + blocks.forEach((b, i) => { + if (b === ore) { + found++; + assert(Math.floor(i / 256) <= 60, "ore above max_y"); + } + }); + } + assert(found > 0, "no ore generated"); + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("mod storage and block data are saved with the world", async () => { + const dir = mods_with_template(); + // a server script that uses storage and block data + Deno.writeTextFileSync( + `${dir}/copper_tools/scripts/server.ts`, + `import type { ServerContext } from "bworld/server"; +export function setup(ctx: ServerContext) { + ctx.components.register_block("copper_tools:announce", { + on_create(block) { block.data = { placed_at: ctx.system.current_tick }; }, + on_interact(block, _params, player) { + ctx.storage.set("clicks", (ctx.storage.get("clicks") ?? 0) + 1); + player.send_message(\`clicks \${ctx.storage.get("clicks")}, placed at \${block.data.placed_at}\`); + return true; + }, + }); +} +`, + ); + let { game, send, take } = await test_game(dir); + game.on_connect(1); + send(1, { type: "hello", name: "bob" }); + for (let i = 0; i < 5; i++) game.tick(); + game.set_block(0, 100, 0, "copper_tools:example_block"); + send(1, { type: "move", x: 0.5, y: 100, z: 1.5, yaw: 0, pitch: 0 }); + send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" }); + assert(take(1).some((m) => m.text === "clicks 1, placed at 5")); + + const saved = game.save(); + ({ game, send, take } = await test_game(dir, saved)); + game.on_connect(1); + send(1, { type: "hello", name: "bob" }); + send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" }); + assert(take(1).some((m) => m.text === "clicks 2, placed at 5")); + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("broken mods stop the game from starting", async () => { + const dir = mods_with_template(); + // the block uses a component the script no longer registers + Deno.writeTextFileSync(`${dir}/copper_tools/scripts/server.ts`, "export function setup() {}\n"); + let error = ""; + try { + await test_game(dir); + } catch (e) { + error = (e as Error).message; + } + assert(error.includes("uses component copper_tools:announce, which nothing registered"), error); + Deno.removeSync(dir, { recursive: true }); +}); diff --git a/tools/check_mods.ts b/tools/check_mods.ts index 320b709..e782e8b 100644 --- a/tools/check_mods.ts +++ b/tools/check_mods.ts @@ -4,10 +4,12 @@ import { BlockJson, FORMAT_VERSION, ItemJson, + OreJson, RecipeJson, validate_block, validate_item, validate_manifest, + validate_ore, validate_recipe, } from "$/common/mod_data.ts"; @@ -17,7 +19,7 @@ export interface ModReport { warnings: string[]; } -interface LoadedMod { +export interface LoadedMod { id: string; dir: string; report: ModReport; @@ -25,22 +27,61 @@ interface LoadedMod { blocks: { file: string; json: BlockJson }[]; items: { file: string; json: ItemJson }[]; recipes: { file: string; json: RecipeJson }[]; + ores: { file: string; json: OreJson }[]; textures: string[]; + // absolute paths by texture id + texture_files: Map; } -// the base game's textures are still built from assets/ until the loader exists (phase 1 in MODS.md), -// and the build names all of them bworld: -const BASE_TEXTURE_DIR = "assets/sprites/textures"; +// the engine's own textures, named engine: +export const ENGINE_TEXTURE_DIR = "assets/sprites/textures"; -export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise { +// loads and checks every mod without typechecking, which is slow. the build uses this +export function load_and_check(mods_dir = "mods"): LoadedMod[] { const mods: LoadedMod[] = []; for (const entry of safe_read_dir(mods_dir)) { if (entry.isDirectory && !entry.name.startsWith(".") && !entry.name.startsWith("_")) { mods.push(load_mod(mods_dir, entry.name)); } } - check_references(mods); + return mods; +} + +// dependencies before the mods that need them, otherwise alphabetical +export function load_order(mods: LoadedMod[]): LoadedMod[] { + const by_id = new Map(mods.map((mod) => [mod.id, mod])); + const ordered: LoadedMod[] = []; + const state = new Map(); + + const visit = (mod: LoadedMod, path: string[]) => { + if (state.get(mod.id) === "done") return; + if (state.get(mod.id) === "visiting") { + throw new Error(`mods depend on each other in a circle: ${[...path, mod.id].join(" -> ")}`); + } + state.set(mod.id, "visiting"); + const dependencies = ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((d) => d.id).sort(); + for (const dependency of dependencies) { + const other = by_id.get(dependency); + if (other) visit(other, [...path, mod.id]); + } + state.set(mod.id, "done"); + ordered.push(mod); + }; + + for (const mod of [...mods].sort((a, b) => a.id.localeCompare(b.id))) { + visit(mod, []); + } + return ordered; +} + +export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise { + const mods = load_and_check(mods_dir); + try { + load_order(mods); + } catch (e) { + for (const mod of mods) mod.report.errors.push((e as Error).message); + } if (options.typecheck) { for (const mod of mods) { @@ -60,7 +101,9 @@ function load_mod(mods_dir: string, id: string): LoadedMod { blocks: [], items: [], recipes: [], + ores: [], textures: [], + texture_files: new Map(), }; const error = (message: string) => mod.report.errors.push(message); @@ -107,6 +150,19 @@ function load_mod(mods_dir: string, id: string): LoadedMod { load_data("items", "item", validate_item, mod.items); load_data("recipes", "recipe", validate_recipe, mod.recipes); + if (exists(`${dir}/worldgen/ores.json`)) { + const ores = read_json(`${dir}/worldgen/ores.json`, (m) => error(`worldgen/ores.json: ${m}`)); + if (!is_object(ores) || ores.format_version !== FORMAT_VERSION || !Array.isArray(ores.ores)) { + error(`worldgen/ores.json: must be { "format_version": ${FORMAT_VERSION}, "ores": [ ... ] }`); + } else { + ores.ores.forEach((ore, i) => { + const problems = validate_ore(ore); + for (const problem of problems) error(`worldgen/ores.json: ore ${i}: ${problem}`); + if (problems.length === 0) mod.ores.push({ file: "worldgen/ores.json", json: ore as OreJson }); + }); + } + } + // a mod only registers ids in its own namespace for (const { file, json } of [...mod.blocks, ...mod.items]) { if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`); @@ -115,6 +171,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod { for (const file of walk(`${dir}/textures`, ".png")) { const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`; mod.textures.push(texture_id); + mod.texture_files.set(texture_id, `${dir}/textures/${file}`); const size = png_size(`${dir}/textures/${file}`); if (!size) { error(`textures/${file}: isn't a png`); @@ -132,8 +189,8 @@ function check_references(mods: LoadedMod[]) { const add = (map: Map, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]); const textures = new Set(); - for (const file of walk(BASE_TEXTURE_DIR, ".png")) { - textures.add(`bworld:${file.replace(/\.png$/, "")}`); + for (const file of walk(ENGINE_TEXTURE_DIR, ".png")) { + textures.add(`engine:${file.replace(/\.png$/, "")}`); } for (const mod of mods) { @@ -198,6 +255,10 @@ function check_references(mods: LoadedMod[]) { break; } } + for (const { file, json } of mod.ores) { + uses(file, json.id, "block"); + uses(file, json.replaces, "block"); + } } } diff --git a/tools/export_bworld_mod.ts b/tools/export_bworld_mod.ts deleted file mode 100644 index 78343af..0000000 --- a/tools/export_bworld_mod.ts +++ /dev/null @@ -1,85 +0,0 @@ -// deno task export-bworld -// writes the base game's blocks, items and recipes into mods/bworld as json, from what the game registers now. -// the game still loads the typescript definitions until the mod loader exists (phase 1 in MODS.md), -// so run this again after changing them. tests/bworld_mod_test.ts fails when the two drift apart -import "$/common/blocks/mod.ts"; -import "$/common/items/mod.ts"; -import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { block_to_json, grid_recipe_to_json, item_to_json, RecipeJson } from "$/common/mod_data.ts"; -import { CRAFTING_RECIPES } from "$/server/game/crafting.ts"; -import { FUEL_VALUES, FURNACE_RECIPES } from "$/server/game/blocks.ts"; - -const MOD_DIR = "mods/bworld"; -const DATA_FOLDERS = ["blocks", "items", "recipes"]; - -function name_of(id: string) { - return id.split(":")[1]; -} - -// everything the base game registers, as the json files mods/bworld should contain -export function bworld_data_files(): { path: string; content: unknown }[] { - const files: { path: string; content: unknown }[] = []; - const add = (folder: string, name: string, key: string, content: unknown) => - files.push({ path: `${folder}/${name}.json`, content: { format_version: 1, [key]: content } }); - - const items = new Map(EverythingRegistry.entries("items")); - - for (const [id, block] of EverythingRegistry.entries("blocks")) { - const has_item = items.get(id)?.block_id === id; - add("blocks", name_of(id), "block", block_to_json(block, has_item)); - } - - for (const [id, item] of items) { - // block items come from their block's "item" field - if (item.block_id === undefined) { - add("items", name_of(id), "item", item_to_json(id, item)); - } - } - - for (const recipe of CRAFTING_RECIPES) { - add("recipes", `crafting_${name_of(recipe.result.id)}`, "recipe", grid_recipe_to_json(recipe)); - } - for (const recipe of FURNACE_RECIPES) { - add( - "recipes", - `smelting_${name_of(recipe.input)}`, - "recipe", - { - type: "furnace", - input: recipe.input, - output: { id: recipe.output.type_id, count: recipe.output.amount }, - cook_time: recipe.cook_time, - } satisfies RecipeJson, - ); - } - for (const [item, burn_time] of Object.entries(FUEL_VALUES)) { - add("recipes", `fuel_${name_of(item)}`, "recipe", { type: "fuel", item, burn_time } satisfies RecipeJson); - } - - return files; -} - -if (import.meta.main) { - for (const folder of DATA_FOLDERS) { - try { - Deno.removeSync(`${MOD_DIR}/${folder}`, { recursive: true }); - } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) throw e; - } - Deno.mkdirSync(`${MOD_DIR}/${folder}`, { recursive: true }); - } - - const files = bworld_data_files(); - for (const { path, content } of files) { - Deno.writeTextFileSync(`${MOD_DIR}/${path}`, JSON.stringify(content, null, "\t") + "\n"); - } - - // match the repo's formatting so reruns don't show up as changes - await new Deno.Command(Deno.execPath(), { args: ["fmt", "--quiet", ...DATA_FOLDERS.map((f) => `${MOD_DIR}/${f}`)] }) - .output(); - - const count = (folder: string) => files.filter((f) => f.path.startsWith(`${folder}/`)).length; - console.log( - `Wrote ${count("blocks")} blocks, ${count("items")} items and ${count("recipes")} recipes to ${MOD_DIR}`, - ); -}