From bb42dd662e738cb5520d2751219bbd6949f6374c Mon Sep 17 00:00:00 2001 From: Paula Date: Thu, 24 Sep 2026 23:28:01 -0300 Subject: [PATCH] Test mod --- MODS.md | 277 +++++++++++++-- common/blocks/water.ts | 1 + common/everything_registry.ts | 8 + common/mod_api/client.ts | 64 ++++ common/mod_api/server.ts | 252 ++++++++++++++ common/mod_api/shared.ts | 33 ++ common/mod_api/worldgen.ts | 31 ++ common/mod_data.ts | 321 ++++++++++++++++++ deno.json | 10 +- deno.lock | 13 +- mods/bworld/CREDITS.md | 82 +++++ mods/bworld/README.md | 21 ++ mods/bworld/blocks/chest.json | 14 + mods/bworld/blocks/coal_ore.json | 13 + mods/bworld/blocks/copper_ore.json | 13 + mods/bworld/blocks/dirt.json | 13 + mods/bworld/blocks/furnace.json | 17 + mods/bworld/blocks/glass.json | 12 + mods/bworld/blocks/gold_ore.json | 13 + mods/bworld/blocks/grass.json | 18 + mods/bworld/blocks/hoed_dirt.json | 24 ++ mods/bworld/blocks/iron_ore.json | 13 + mods/bworld/blocks/leaves.json | 12 + mods/bworld/blocks/log.json | 16 + mods/bworld/blocks/planks.json | 12 + mods/bworld/blocks/sand.json | 12 + mods/bworld/blocks/snow.json | 12 + mods/bworld/blocks/stone.json | 13 + mods/bworld/blocks/tin_ore.json | 13 + mods/bworld/blocks/water.json | 12 + mods/bworld/items/axe.json | 8 + mods/bworld/items/coal.json | 7 + mods/bworld/items/copper_ingot.json | 7 + mods/bworld/items/gold_ingot.json | 7 + mods/bworld/items/hoe.json | 7 + mods/bworld/items/iron_ingot.json | 7 + mods/bworld/items/pickaxe.json | 7 + mods/bworld/items/stick.json | 7 + mods/bworld/items/tin_ingot.json | 7 + mods/bworld/items/watering_can.json | 7 + mods/bworld/items/wood_pickaxe.json | 8 + mods/bworld/manifest.json | 10 + mods/bworld/recipes/crafting_chest.json | 18 + mods/bworld/recipes/crafting_furnace.json | 18 + mods/bworld/recipes/crafting_planks.json | 16 + mods/bworld/recipes/crafting_stick.json | 17 + .../bworld/recipes/crafting_wood_pickaxe.json | 19 ++ mods/bworld/recipes/fuel_coal.json | 8 + mods/bworld/recipes/fuel_log.json | 8 + mods/bworld/recipes/smelting_coal_ore.json | 12 + mods/bworld/recipes/smelting_copper_ore.json | 12 + mods/bworld/recipes/smelting_gold_ore.json | 12 + mods/bworld/recipes/smelting_iron_ore.json | 12 + mods/bworld/recipes/smelting_log.json | 12 + mods/bworld/recipes/smelting_tin_ore.json | 12 + server/game/blocks.ts | 6 +- server/game/crafting.ts | 4 +- templates/mod/CREDITS.md | 3 + templates/mod/README.md | 31 ++ templates/mod/blocks/example_block.json | 13 + templates/mod/items/example_item.json | 8 + templates/mod/manifest.json | 18 + templates/mod/recipes/example_block.json | 9 + templates/mod/scripts/client.ts | 14 + templates/mod/scripts/server.ts | 26 ++ templates/mod/scripts/worldgen.ts | 15 + templates/mod/textures/example_block.png | Bin 0 -> 201 bytes templates/mod/textures/example_item.png | Bin 0 -> 189 bytes tests/bworld_mod_test.ts | 65 ++++ tests/mod_tools_test.ts | 103 ++++++ tools/check_mods.ts | 295 ++++++++++++++++ tools/export_bworld_mod.ts | 85 +++++ tools/new_mod.ts | 58 ++++ 73 files changed, 2349 insertions(+), 34 deletions(-) create mode 100644 common/mod_api/client.ts create mode 100644 common/mod_api/server.ts create mode 100644 common/mod_api/shared.ts create mode 100644 common/mod_api/worldgen.ts create mode 100644 common/mod_data.ts create mode 100644 mods/bworld/CREDITS.md create mode 100644 mods/bworld/README.md create mode 100644 mods/bworld/blocks/chest.json create mode 100644 mods/bworld/blocks/coal_ore.json create mode 100644 mods/bworld/blocks/copper_ore.json create mode 100644 mods/bworld/blocks/dirt.json create mode 100644 mods/bworld/blocks/furnace.json create mode 100644 mods/bworld/blocks/glass.json create mode 100644 mods/bworld/blocks/gold_ore.json create mode 100644 mods/bworld/blocks/grass.json create mode 100644 mods/bworld/blocks/hoed_dirt.json create mode 100644 mods/bworld/blocks/iron_ore.json create mode 100644 mods/bworld/blocks/leaves.json create mode 100644 mods/bworld/blocks/log.json create mode 100644 mods/bworld/blocks/planks.json create mode 100644 mods/bworld/blocks/sand.json create mode 100644 mods/bworld/blocks/snow.json create mode 100644 mods/bworld/blocks/stone.json create mode 100644 mods/bworld/blocks/tin_ore.json create mode 100644 mods/bworld/blocks/water.json create mode 100644 mods/bworld/items/axe.json create mode 100644 mods/bworld/items/coal.json create mode 100644 mods/bworld/items/copper_ingot.json create mode 100644 mods/bworld/items/gold_ingot.json create mode 100644 mods/bworld/items/hoe.json create mode 100644 mods/bworld/items/iron_ingot.json create mode 100644 mods/bworld/items/pickaxe.json create mode 100644 mods/bworld/items/stick.json create mode 100644 mods/bworld/items/tin_ingot.json create mode 100644 mods/bworld/items/watering_can.json create mode 100644 mods/bworld/items/wood_pickaxe.json create mode 100644 mods/bworld/manifest.json create mode 100644 mods/bworld/recipes/crafting_chest.json create mode 100644 mods/bworld/recipes/crafting_furnace.json create mode 100644 mods/bworld/recipes/crafting_planks.json create mode 100644 mods/bworld/recipes/crafting_stick.json create mode 100644 mods/bworld/recipes/crafting_wood_pickaxe.json create mode 100644 mods/bworld/recipes/fuel_coal.json create mode 100644 mods/bworld/recipes/fuel_log.json create mode 100644 mods/bworld/recipes/smelting_coal_ore.json create mode 100644 mods/bworld/recipes/smelting_copper_ore.json create mode 100644 mods/bworld/recipes/smelting_gold_ore.json create mode 100644 mods/bworld/recipes/smelting_iron_ore.json create mode 100644 mods/bworld/recipes/smelting_log.json create mode 100644 mods/bworld/recipes/smelting_tin_ore.json create mode 100644 templates/mod/CREDITS.md create mode 100644 templates/mod/README.md create mode 100644 templates/mod/blocks/example_block.json create mode 100644 templates/mod/items/example_item.json create mode 100644 templates/mod/manifest.json create mode 100644 templates/mod/recipes/example_block.json create mode 100644 templates/mod/scripts/client.ts create mode 100644 templates/mod/scripts/server.ts create mode 100644 templates/mod/scripts/worldgen.ts create mode 100644 templates/mod/textures/example_block.png create mode 100644 templates/mod/textures/example_item.png create mode 100644 tests/bworld_mod_test.ts create mode 100644 tests/mod_tools_test.ts create mode 100644 tools/check_mods.ts create mode 100644 tools/export_bworld_mod.ts create mode 100644 tools/new_mod.ts diff --git a/MODS.md b/MODS.md index 548ebbe..1373860 100644 --- a/MODS.md +++ b/MODS.md @@ -14,6 +14,7 @@ too, since the client is already a web page. ## Contents - [Overview](#overview) +- [Creating a mod](#creating-a-mod) - [Mod layout](#mod-layout) - [manifest.json](#manifestjson) - [Identifiers](#identifiers) @@ -29,6 +30,7 @@ too, since the client is already a web page. - [Delivery to clients](#delivery-to-clients) - [Security](#security) - [Example mod](#example-mod) +- [The base game as a mod](#the-base-game-as-a-mod) - [Implementation plan](#implementation-plan) - [Open questions](#open-questions) @@ -64,6 +66,21 @@ result of breaking and placing immediately, and the server sends a correction if Moving from the current setup to this is a big change, see the [Implementation plan](#implementation-plan). Right now the server only relays block changes and doesn't know about blocks, inventories or terrain. +## Creating a mod + +```sh +deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/copper_tools +deno task check-mods # validates every mod in mods/ +``` + +The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and all three +scripts. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a mod +refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in +`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). + ## Mod layout ``` @@ -119,6 +136,7 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def | `scripts.server` | no | Server entry. Never sent to clients. | | `scripts.client` | no | Client entry, sent to every player. | | `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. | +| `credits` | no | A markdown file in the mod, shown on the About page. For asset licenses and thanks. | Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by both the server and client entries is copied into both bundles, so **don't import secrets into shared code**. Anything the client @@ -128,8 +146,11 @@ bundle imports is visible to players. Everything a mod registers has an id of the form `namespace:name`: -- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$`, be at most 32 characters, and not be `bworld`, which is - the base game. +- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$` and be at most 32 characters. +- Two namespaces are reserved. `bworld` belongs to the base game mod shipped in `mods/bworld` (see + [The base game as a mod](#the-base-game-as-a-mod)). `engine` belongs to the engine itself, for things every game + needs, like the block-breaking cracks. The one exception is `bworld:air`: it's the engine's empty block, but it keeps + that id because every save already contains it. - `name` must match `^[a-z0-9_]+$`. - A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can _refer to_ any id. @@ -144,7 +165,9 @@ program**, so the server and each client can number blocks differently. Saves an `_`, so `textures/ores/tin.png` becomes `:ores_tin`. - Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and clients download that atlas instead of using their own. -- A missing texture shows the magenta and black checker and logs a warning. It isn't a load error. +- A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error. +- The engine's own textures use the `engine` namespace: `engine:missing` and the breaking cracks `engine:break_0` to + `engine:break_8`. They come from `assets/`, not from a mod. ## Blocks @@ -167,20 +190,22 @@ program**, so the server and each client can number blocks differently. Saves an } ``` -| Field | Default | Maps to `BlockRegistry` | Meaning | -| ---------------------- | ----------- | ----------------------- | --------------------------------------------------------------- | -| `id` | required | `id` | The block's id. | -| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. | -| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. | -| `alpha` | `1` | `alpha` | Opacity for transparent blocks. | -| `collision` | `true` | `has_collision` | Whether entities collide with it. | -| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. | -| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. | -| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. | -| `drops` | nothing | `drop_table` | Item id given when broken. | -| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | -| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. | -| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. | +| Field | Default | Maps to `BlockRegistry` | Meaning | +| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- | +| `id` | required | `id` | The block's id. | +| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. | +| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. | +| `alpha` | `1` | `alpha` | Opacity for transparent blocks. | +| `collision` | `true` | `has_collision` | Whether entities collide with it. | +| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. | +| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. | +| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. | +| `drops` | nothing | `drop_table` | Item id given when broken. | +| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | +| `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. | +| `replaceable` | `false` | new | Placing a block into it replaces it, like water. | +| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. | +| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. | The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain functions. @@ -235,13 +260,28 @@ clients as part of the container it's in, so client screens can show it. } ``` -| `type` | Fields | Notes | -| --------- | --------------------------------------------------------------- | ----------------------------------------------- | -| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as the table in `client/blocks/furnace.ts` | -| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `furnace.ts` | +| `type` | Fields | Notes | +| --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Fits anywhere in the 3×3 grid, like the recipes in `server/game/crafting.ts` | +| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as `FURNACE_RECIPES` in `server/game/blocks.ts` | +| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `server/game/blocks.ts` | -Two recipes with the same `input` are a load error. Server scripts can also register recipe types of their own through -`ctx.recipes`. Crafting recipes are reserved until the game has crafting. +A shaped recipe, for the crafting grid in the player's inventory screen: + +```json +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": ["PPP", "P P", "PPP"], + "key": { "P": "bworld:planks" }, + "result": { "id": "bworld:chest", "count": 1 } + } +} +``` + +Two furnace recipes with the same `input`, or two shaped recipes with the same pattern and key, are a load error. Server +scripts can also register recipe types of their own through `ctx.recipes`. ## Server scripts @@ -353,6 +393,7 @@ interface Player { readonly position: { x: number; y: number; z: number }; readonly inventory: Container; // 36 slots, hotbar is 0-8 readonly selected_slot: number; + readonly held_item: ItemStack | undefined; give_item(id: string, count?: number, data?: unknown): void; send_message(text: string): void; teleport(x: number, y: number, z: number): void; @@ -656,18 +697,75 @@ ctx.net.send("copper_tools:reset_stats", {}); Chunks are generated from the world seed by chunk workers on **both** the server and every client, and all of them must produce identical terrain. So worldgen scripts must be **deterministic**. -`worldgen/ores.json` uses the same fields as the `ORES` table in `client/generation.ts`: +Each chunk is generated in three passes: + +1. **Terrain.** One terrain generator fills in the ground, water and trees. +2. **Ores.** Every mod's `worldgen/ores.json`, in load order. +3. **Features.** Every mod's registered features, in load order. + +### Terrain generators + +A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`. +Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN` +setting when there are several. A server with no terrain generator refuses to start. + +```ts +export function setup(gen: WorldgenContext) { + gen.register_terrain("bworld:overworld", (chunk) => { + const height_noise = chunk.noise_2d("height"); + for (let x = 0; x < 16; x++) { + for (let z = 0; z < 16; z++) { + const wx = chunk.x * 16 + x; + const wz = chunk.z * 16 + z; + const height = Math.floor((height_noise(wx * 0.01, wz * 0.01) + 1) * 15 + 50); + for (let y = 0; y <= height; y++) { + chunk.set_block(wx, y, wz, y === height ? "bworld:grass" : "bworld:stone"); + } + chunk.set_height(wx, wz, height); + chunk.set_biome(wx, wz, "bworld:plains"); + } + } + }); +} +``` + +A terrain generator gets a `TerrainChunk`: a `FeatureChunk` (below) plus `set_height(x, z, height)` and +`set_biome(x, z, biome)`, which feed `height_at` / `biome_at` in later passes. Its `set_block` can write up to one chunk +away, like trees. Writes into another chunk only fill air, so the result doesn't depend on which chunk generates first. + +Seeding is exact, so a generator ported from the current code produces the same terrain: + +- `noise_2d(name)` is `create_noise_2d(new Alea(seed + "_" + name))` from `@paulaboks/rng`, and `noise_3d` is the same + with `create_noise_3d`. Both are cached per seed and name. +- A terrain generator's `rng` is `new Alea(seed + "_chunk_" + x + "_" + z)`. +- A feature's `rng` is `new Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z)`. + +### Ores + +`worldgen/ores.json` uses the same fields as the `ORES` table in `common/generation.ts`, plus the block they replace: ```json { "format_version": 1, "ores": [ - { "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 } + { + "id": "copper_tools:rich_copper_ore", + "replaces": "bworld:stone", + "min_y": 5, + "max_y": 40, + "scale": 0.05, + "threshold": 0.72 + } ] } ``` -`scripts.worldgen` registers features, which run on each chunk after the base terrain and trees: +Each block in the chunk that is `replaces` and between `min_y` and `max_y` becomes the first ore whose `noise_3d(id)` at +`(x, y, z) * scale` is above `threshold`. + +### Features + +`scripts.worldgen` registers features, which run on each chunk after terrain and ores: ```ts import type { WorldgenContext } from "bworld/worldgen"; @@ -882,6 +980,130 @@ class SmelterStats implements ModScreen { } ``` +## The base game as a mod + +Everything that makes bworld _bworld_ (its blocks, items, recipes, textures, block behavior and terrain) moves into a +mod at `mods/bworld`, written against the same API as any other mod. The engine keeps only what every game built on it +needs. + +This is how the mod API gets tested for real: if the base game can't be written as a mod, the API is missing something, +and other mods would hit the same wall. It also means there's one way content works instead of two, so mods can do +anything the base game does. + +### What moves + +| Content | Now | In `mods/bworld` | +| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | +| 18 blocks | `common/blocks/*.ts` | `blocks/*.json` | +| 11 items | `common/items/*.ts` | `items/*.json` | +| 62 textures | `assets/sprites/textures/` | `textures/` | +| 5 crafting recipes | `server/game/crafting.ts` | `recipes/*.json`, type `shaped` | +| 6 furnace recipes, 2 fuels | `server/game/blocks.ts` | `recipes/*.json`, types `furnace` and `fuel` | +| Hoeing grass and dirt | `server/game/blocks.ts` | component `bworld:hoeable`, params `{ tool, into }` | +| Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` | +| Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` | +| Watering can's starting water | `common/items/watering_can.ts` | item component `bworld:watering_can`, params `{ max_water }` | +| Terrain, biomes and trees | `common/generation.ts` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` | +| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) | +| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` | + +Not moved: + +- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt + as a component once server scripts exist, as a good first test of tile data plus ticking. +- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves + once tooltips exist. + +### What stays in the engine + +Rendering, physics and player controls; chunks, meshing and the generation passes (noise helpers, chunk assembly, ores, +features); networking, saving and the game server; registries and the mod loader; inventories, the crafting grid, slot +click rules and the generic container screen; chat and `/give`; `bworld:air`; and the engine assets in `assets/`: the UI +sprites, font, player sprite and the `engine:` textures (the breaking cracks and the missing texture). + +### Rules that keep existing worlds working + +Worlds saved before the move must load afterwards with nothing changed: + +1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. The + JSON uses exactly the ids the TypeScript files register now. +2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If + the generator's output changes at all, every existing world silently changes with it: trees move and blocks players + broke reappear. The golden terrain test (phase 0) enforces this. +3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main` + plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved + by the current code load into them. +4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod. + +### Phases + +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. + +- 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 + end-to-end WebSocket test. +- Record golden fixtures from the current code: `tests/fixtures/registry.json`, every block and item with all its fields + (functions left out), and `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds, + including negative coordinates and chunks with trees on their borders. +- 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). + +- 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. +- 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/`. +- Check: the registry built from JSON equals `registry.json`, and all tests pass. + +**Phase 2: behavior** (after step 6, and container screens from step 7). + +- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and + `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the + `ScreenLayout`s the server sends now. +- The block JSON lists the components. Delete `server/game/blocks.ts`. +- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and + `world_v2.json` still pass. + +**Phase 3: world generation** (after step 9). + +- Port `generate_chunk` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a + straight port: the seeding rules in [Terrain generators](#terrain-generators) were chosen so the same noise names give + the same values. +- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be + overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and + the terrain would change. +- Try moving the ores to `ores.json` with `"replaces": "bworld:stone"`. The current code only places ores in stone, so + this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain + generator. +- Remove the content from `common/generation.ts`, leaving the passes and noise helpers. +- Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged. + +**Phase 4: engine cleanup.** + +- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the + `replaceable` block field. +- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have + the same name. +- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means + content. +- The About page shows the engine's `assets/ASSETS.md` plus every loaded mod's credits. +- The server refuses to start without a terrain generator, naming the mods that could provide one. + +### Done when + +- Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`. +- Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing + somewhere. +- All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it. + ## Implementation plan Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working: @@ -911,6 +1133,9 @@ Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps 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. +Moving the base game into `mods/bworld` happens alongside steps 4–9. See +[The base game as a mod](#the-base-game-as-a-mod) for which phase follows which step. + ## Open questions - **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to diff --git a/common/blocks/water.ts b/common/blocks/water.ts index a6373c7..49d1e23 100644 --- a/common/blocks/water.ts +++ b/common/blocks/water.ts @@ -6,4 +6,5 @@ EverythingRegistry.register("blocks", "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 c65b06e..9447d9d 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -48,6 +48,12 @@ export class EverythingRegistry { static get_registry(registry: string): T[] { return this.#id_to_value.get(registry) as T[]; } + + // [key, value] pairs in registration order + static entries(registry: string): [string, T][] { + const values = this.#id_to_value.get(registry) ?? []; + return [...(this.#key_to_id.get(registry) ?? [])].map(([key, id]) => [key, values[id] as T]); + } } interface TextureSideTopBottom { @@ -96,6 +102,8 @@ export interface BlockRegistry { // right clicking it does something instead of placing a block, clients don't predict placing against it. // behavior runs on the server, see server/game/blocks.ts interactive?: boolean; + // placing a block into it replaces it, like water + replaceable?: boolean; compiled_states?: CompiledStateDefinition[]; } diff --git a/common/mod_api/client.ts b/common/mod_api/client.ts new file mode 100644 index 0000000..62d812d --- /dev/null +++ b/common/mod_api/client.ts @@ -0,0 +1,64 @@ +// what client scripts get, see "Client scripts" and "GUIs" in MODS.md +import type { Id, KeyCode, ModInfo, Position } from "./shared.ts"; + +export type { Id, ItemStack, KeyCode, ModInfo, Position } from "./shared.ts"; + +export interface ClientContext { + mod: ModInfo; + ui: ClientUi; + hud: HudRegistry; + input: { bind(id: Id, default_key: KeyCode, on_press: () => void): void }; + net: ClientNet; + player: { readonly name: string; readonly position: Readonly }; + // read only, what this client sees + world: { get_block(x: number, y: number, z: number): Id | undefined }; + log(...args: unknown[]): void; +} + +export interface ModScreen { + on_open?(): void; + on_tick?(dt: number): void; + on_render(g: Graphics): void; + // the server sent new props for this screen + on_props?(props: Props): void; + on_close?(): void; + // return true to keep the screen open when escape is pressed + on_escape?(): boolean; +} + +export type Color = [number, number, number, number]; + +// immediate mode drawing, like the debug ui, styled with assets/sprites/ui.png +export interface Graphics { + readonly width: number; + readonly height: number; + readonly mouse: { x: number; y: number; down: boolean; pressed: boolean }; + rect(x: number, y: number, w: number, h: number, color?: Color): void; + panel(x: number, y: number, w: number, h: number): void; + text(text: string, x: number, y: number, options?: { scale?: number; color?: Color }): void; + measure_text(text: string, scale?: number): number; + texture(id: Id, x: number, y: number, w: number, h: number): void; + item(id: Id, x: number, y: number, count?: number): void; + clip(x: number, y: number, w: number, h: number, draw: () => void): void; + button(label: string, x: number, y: number, w: number, h: number): boolean; + text_input(id: string, x: number, y: number, w: number): string; + slider(id: string, x: number, y: number, w: number, min: number, max: number): number; + // server synced slots, same rules as container screens + slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void; + key_pressed(key: KeyCode): boolean; +} + +export interface ClientUi { + register_screen(id: Id, create: (props: Props) => ModScreen): void; + // open a screen that doesn't involve the server, like a settings page + open(id: Id, props: Props): void; +} + +export interface HudRegistry { + register(id: Id, element: { on_render(g: Graphics): void }): void; +} + +export interface ClientNet { + on(channel: Id, handler: (data: T) => void): void; + send(channel: Id, data: unknown): void; +} diff --git a/common/mod_api/server.ts b/common/mod_api/server.ts new file mode 100644 index 0000000..e16db92 --- /dev/null +++ b/common/mod_api/server.ts @@ -0,0 +1,252 @@ +// what server scripts get, see "Server scripts" in MODS.md +import type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts"; + +export type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts"; + +export interface ServerContext { + mod: ModInfo; + components: ComponentRegistry; // only during setup + commands: CommandRegistry; // only during setup + events: { before: ServerBeforeEvents; after: ServerAfterEvents }; + system: System; + world: ServerWorld; + players: PlayerList; + containers: ContainerApi; + recipes: RecipeApi; + ui: ServerUi; + net: ServerNet; + storage: ModStorage; + log(...args: unknown[]): void; +} + +// components + +export interface BlockRef { + readonly id: Id; + readonly x: number; + readonly y: number; + readonly z: number; + // tile data, any json value. saved with the world, never sent to clients + // deno-lint-ignore no-explicit-any + data: any; +} + +// P is the component's params from the block json +// deno-lint-ignore no-explicit-any +export interface BlockComponent

{ + on_create?(block: BlockRef, params: P): void; + on_break?(block: BlockRef, params: P, player: Player | undefined): void; + on_click?(block: BlockRef, params: P, player: Player): void; + // return true if it did something, so no block gets placed + on_interact?(block: BlockRef, params: P, player: Player): boolean; + on_tick?(block: BlockRef, params: P, dt: number): void; + on_second?(block: BlockRef, params: P, dt: number): void; +} + +// deno-lint-ignore no-explicit-any +export interface ItemComponent

{ + on_create?(item: ItemStack, params: P): void; + get_lore?(item: ItemStack, params: P): string; + on_use?(item: ItemStack, params: P, player: Player): void; +} + +export interface ComponentRegistry { + register_block

(id: Id, component: BlockComponent

): void; + register_item

(id: Id, component: ItemComponent

): void; +} + +// commands + +export interface Command { + description: string; + usage: string; + run(args: string[], player: Player): void; +} + +export interface CommandRegistry { + register(name: string, command: Command): void; +} + +// events + +export interface Cancelable { + cancel: boolean; +} + +export interface BlockBreakEvent { + readonly player: Player; + readonly block: BlockRef; + readonly item: ItemStack | undefined; +} + +export interface BlockPlaceEvent { + readonly player: Player; + readonly block: { readonly id: Id } & Position; + readonly face: Face; + readonly item: ItemStack | undefined; +} + +export interface BlockInteractEvent { + readonly player: Player; + readonly block: BlockRef; + readonly item: ItemStack | undefined; +} + +export interface ChatSendEvent { + readonly player: Player; + message: string; +} + +export interface ServerBeforeEvents { + block_break: EventSignal; + block_place: EventSignal; + block_interact: EventSignal; + chat_send: EventSignal; +} + +export interface ServerAfterEvents { + block_break: EventSignal; + block_place: EventSignal; + block_interact: EventSignal; + chat_send: EventSignal>; + player_join: EventSignal<{ readonly player: Player }>; + player_leave: EventSignal<{ readonly player: Player }>; + server_start: EventSignal>; + tick: EventSignal<{ readonly dt: number }>; +} + +// world and players + +export interface ServerWorld { + get_block(x: number, y: number, z: number): Id | undefined; // undefined when the chunk isn't loaded + set_block(x: number, y: number, z: number, id: Id): boolean; // runs on_break / on_create, synced to everyone + get_state(x: number, y: number, z: number, name: string): number | undefined; + set_state(x: number, y: number, z: number, name: string, value: number): boolean; + get_block_data(x: number, y: number, z: number): T | undefined; + is_loaded(x: number, z: number): boolean; + readonly seed: string; +} + +export interface Player { + readonly id: string; + readonly name: string; + readonly position: Readonly; + readonly inventory: Container; // 36 slots, hotbar is 0-8 + readonly selected_slot: number; + readonly held_item: ItemStack | undefined; + give_item(id: Id, count?: number, data?: unknown): void; + send_message(text: string): void; + teleport(x: number, y: number, z: number): void; +} + +export interface PlayerList { + all(): Player[]; + get(id: string): Player | undefined; + by_name(name: string): Player | undefined; +} + +export interface System { + run_timeout(fn: () => void, ticks: number): number; + run_interval(fn: () => void, ticks: number): number; + clear_run(handle: number): void; + readonly current_tick: number; +} + +// small key value store per mod, saved with the world +export interface ModStorage { + get(key: string): T | undefined; + set(key: string, value: unknown): void; // json values only + delete(key: string): void; +} + +// containers and recipes + +export interface Container { + readonly id: string; + readonly size: number; + get(slot: number): ItemStack | undefined; + set(slot: number, item: ItemStack | undefined): void; + add(item: ItemStack): ItemStack | undefined; // returns what didn't fit + on_change(fn: (slot: number) => void): () => void; +} + +export interface ContainerApi { + create(size: number): Container; // saved with the world + get(id: string): Container | undefined; + delete(id: string): void; +} + +export interface RecipeApi { + furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined; + fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel + is_fuel(item: Id): boolean; + is_smeltable(item: Id): boolean; +} + +// guis + +export interface ActionForm { + title: string; + body?: string; + buttons: { text: string; icon?: Id }[]; +} + +export interface MessageForm { + title: string; + body: string; + buttons: [string, string]; +} + +export type FormField = + | { type: "toggle"; label: string; default?: boolean } + | { type: "slider"; label: string; min: number; max: number; step?: number; default?: number } + | { type: "dropdown"; label: string; options: string[]; default?: number } + | { type: "text"; label: string; placeholder?: string; default?: string; max_length?: number }; + +export interface ModalForm { + title: string; + fields: FormField[]; +} + +export type FormResult = ({ canceled: true } & Partial) | ({ canceled: false } & T); + +export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean); + +export interface ContainerScreenOptions { + title: string; + container: Container; + // x and y in slot units + layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[]; + player_inventory?: boolean; + bars?: { id: string; x: number; y: number; texture: Id }[]; + labels?: { x: number; y: number; property: string }[]; +} + +export interface ScreenHandle { + readonly player: Player; + set_property(id: string, value: number): void; + update(props: Props): void; // custom screens only + close(): void; + on_close(fn: () => void): void; +} + +export interface ServerUi { + message_form(player: Player, form: MessageForm): Promise>; + action_form(player: Player, form: ActionForm): Promise>; + modal_form(player: Player, form: ModalForm): Promise>; + open_container(player: Player, options: ContainerScreenOptions): ScreenHandle; + open_screen( + player: Player, + id: Id, + props: Props, + options?: { containers?: Record }, + ): ScreenHandle; +} + +// mod channels + +export interface ServerNet { + on(channel: Id, handler: (player: Player, data: T) => void): void; + send(player: Player, channel: Id, data: unknown): void; + broadcast(channel: Id, data: unknown): void; +} diff --git a/common/mod_api/shared.ts b/common/mod_api/shared.ts new file mode 100644 index 0000000..2d0bd49 --- /dev/null +++ b/common/mod_api/shared.ts @@ -0,0 +1,33 @@ +// types every side of a mod shares. these are the api mods see, not the engine's own classes: +// items are { id, count, data } here, like on the network + +export type Id = string; + +export interface ItemStack { + readonly id: Id; + count: number; + // any json value, set by server scripts + data?: unknown; +} + +export interface ModInfo { + readonly id: string; + readonly version: string; +} + +export interface Position { + x: number; + y: number; + z: number; +} + +export type Face = "west" | "east" | "bottom" | "top" | "north" | "south"; + +// KeyboardEvent.code values, like "KeyE" or "Digit1" +export type KeyCode = string; + +export type Unsubscribe = () => void; + +export interface EventSignal { + subscribe(handler: (event: T) => void): Unsubscribe; +} diff --git a/common/mod_api/worldgen.ts b/common/mod_api/worldgen.ts new file mode 100644 index 0000000..1141c9d --- /dev/null +++ b/common/mod_api/worldgen.ts @@ -0,0 +1,31 @@ +// what worldgen scripts get, see "World generation" in MODS.md. runs in chunk workers and must be deterministic +import type { Id } from "./shared.ts"; + +export type { Id } from "./shared.ts"; + +export interface WorldgenContext { + register_terrain(id: Id, generate: (chunk: TerrainChunk) => void): void; + register_feature(id: Id, generate: (chunk: FeatureChunk) => void): void; +} + +export interface FeatureChunk { + // chunk coordinates + readonly x: number; + readonly z: number; + readonly seed: string; + // seeded from the seed, chunk and feature id + readonly rng: { next(): number }; + // create_noise_2d(new Alea(seed + "_" + name)), cached per seed and name + noise_2d(name: string): (x: number, z: number) => number; + noise_3d(name: string): (x: number, y: number, z: number) => number; + // surface height and biome, inside this chunk only + height_at(x: number, z: number): number; + biome_at(x: number, z: number): Id; + get_block(x: number, y: number, z: number): Id | undefined; // inside this chunk only + set_block(x: number, y: number, z: number, id: Id): void; // up to one chunk away, like trees +} + +export interface TerrainChunk extends FeatureChunk { + set_height(x: number, z: number, height: number): void; + set_biome(x: number, z: number, biome: Id): void; +} diff --git a/common/mod_data.ts b/common/mod_data.ts new file mode 100644 index 0000000..0a6c125 --- /dev/null +++ b/common/mod_data.ts @@ -0,0 +1,321 @@ +// 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 +import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts"; + +export const FORMAT_VERSION = 1; +export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; +export const NAMESPACE_PATTERN = /^[a-z0-9_]{1,32}$/; +export const RESERVED_NAMESPACES = ["bworld", "engine"]; + +type BlockTextures = BlockRegistry["textures"]; + +export interface ManifestJson { + format_version: number; + id: string; + name: string; + description?: string; + version: string; + authors?: string[]; + game_version?: string; + dependencies?: { id: string; version: string }[]; + scripts?: { server?: string; client?: string; worldgen?: string }; + credits?: string; +} + +export interface BlockJson { + id: string; + textures: BlockTextures; + transparent?: boolean; + alpha?: number; + collision?: boolean; + mining?: { toughness: number; tool?: string; requires_tool?: boolean }; + drops?: string; + item?: boolean; + interactive?: boolean; + replaceable?: boolean; + states?: BlockStateDefinition[]; + components?: Record; +} + +export interface ItemJson { + id: string; + texture: string; + tool?: string; + places?: string; + max_stack?: number; + lore?: string; + components?: Record; +} + +export type RecipeJson = + | { + type: "shaped"; + pattern: string[]; + key: Record; + result: { id: string; count: number }; + } + | { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number } + | { type: "fuel"; item: string; burn_time: number }; + +// the crafting grid's format, see server/game/crafting.ts +export interface GridRecipe { + width: number; + height: number; + pattern: (string | undefined)[]; + result: { id: string; count: number }; +} + +// blocks + +export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { + const json: BlockJson = { id: block.id, textures: block.textures }; + if (block.transparent) json.transparent = true; + if (block.alpha !== undefined) json.alpha = block.alpha; + if (!block.has_collision) json.collision = false; + if (block.toughness !== undefined) { + json.mining = { toughness: block.toughness }; + if (block.tool_to_break !== undefined) json.mining.tool = block.tool_to_break; + if (block.requires_tool) json.mining.requires_tool = true; + } + if (block.drop_table !== undefined) json.drops = block.drop_table; + if (!has_item) json.item = false; + if (block.interactive) json.interactive = true; + if (block.replaceable) json.replaceable = true; + if (block.states) json.states = block.states; + return json; +} + +export function block_from_json(json: BlockJson): { block: BlockRegistry; has_item: boolean } { + const block: BlockRegistry = { + id: json.id, + textures: json.textures, + has_collision: json.collision ?? true, + }; + if (json.transparent) block.transparent = true; + if (json.alpha !== undefined) block.alpha = json.alpha; + if (json.mining) { + block.toughness = json.mining.toughness; + block.requires_tool = json.mining.requires_tool ?? false; + if (json.mining.tool !== undefined) block.tool_to_break = json.mining.tool; + } + if (json.drops !== undefined) block.drop_table = json.drops; + if (json.interactive) block.interactive = true; + if (json.replaceable) block.replaceable = true; + if (json.states) block.states = json.states; + return { block, has_item: json.item ?? true }; +} + +// items that aren't the item form of a block + +export function item_to_json(id: string, item: ItemRegistry): ItemJson { + if (typeof item.texture_id !== "string") { + throw new Error(`${id} picks its texture with a function, which json can't hold`); + } + 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; + return json; +} + +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; + return item; +} + +// shaped recipes <-> the grid format + +export function grid_recipe_to_json(recipe: GridRecipe): RecipeJson { + const key: Record = {}; + const letters = new Map(); + for (const id of recipe.pattern) { + if (id === undefined || letters.has(id)) continue; + const letter = pick_letter(id, new Set(letters.values())); + letters.set(id, letter); + key[letter] = id; + } + + const pattern: string[] = []; + for (let y = 0; y < recipe.height; y++) { + let row = ""; + for (let x = 0; x < recipe.width; x++) { + const id = recipe.pattern[y * recipe.width + x]; + row += id === undefined ? " " : letters.get(id); + } + pattern.push(row); + } + + return { type: "shaped", pattern, key, result: recipe.result }; +} + +export function grid_recipe_from_json(json: Extract): GridRecipe { + const height = json.pattern.length; + const width = Math.max(...json.pattern.map((row) => row.length)); + const pattern: (string | undefined)[] = []; + for (const row of json.pattern) { + for (let x = 0; x < width; x++) { + const letter = row[x] ?? " "; + pattern.push(letter === " " ? undefined : json.key[letter]); + } + } + return { width, height, pattern, result: json.result }; +} + +// a readable letter for an item in a pattern: its first letter if free, like P for planks +function pick_letter(id: string, taken: Set) { + const name = id.split(":")[1].toUpperCase(); + for (const letter of [...name, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]) { + if (/[A-Z]/.test(letter) && !taken.has(letter)) { + return letter; + } + } + throw new Error(`Ran out of letters for ${id}`); +} + +// validation. every function returns a list of problems, empty when it's fine + +type Problems = string[]; + +export function validate_manifest(json: unknown, folder_name: string): Problems { + const problems: Problems = []; + if (!is_object(json)) return ["manifest.json must be an object"]; + + if (json.format_version !== FORMAT_VERSION) { + problems.push(`format_version must be ${FORMAT_VERSION}`); + } + if (typeof json.id !== "string" || !NAMESPACE_PATTERN.test(json.id)) { + problems.push("id must be 1-32 characters of a-z, 0-9 and _"); + } else if (json.id !== folder_name) { + problems.push(`id "${json.id}" must match the folder name "${folder_name}"`); + } + if (typeof json.name !== "string" || json.name.length === 0) problems.push("name is required"); + if (typeof json.version !== "string" || !/^\d+\.\d+\.\d+/.test(json.version)) { + problems.push("version must be semver, like 1.0.0"); + } + if (json.dependencies !== undefined) { + if (!Array.isArray(json.dependencies)) { + problems.push("dependencies must be a list"); + } else { + for (const dep of json.dependencies) { + if (!is_object(dep) || typeof dep.id !== "string" || typeof dep.version !== "string") { + problems.push("each dependency needs an id and a version range"); + } + } + } + } + if (json.scripts !== undefined) { + if (!is_object(json.scripts)) { + problems.push("scripts must be an object"); + } else { + for (const [side, path] of Object.entries(json.scripts)) { + if (!["server", "client", "worldgen"].includes(side)) problems.push(`unknown script "${side}"`); + if (typeof path !== "string") problems.push(`scripts.${side} must be a path`); + } + } + } + return problems; +} + +export function validate_block(json: unknown): Problems { + if (!is_object(json)) return ["block must be an object"]; + const problems = validate_id(json.id, "id"); + const textures = json.textures; + const texture_ok = typeof textures === "string" || + (is_object(textures) && + (["top", "bottom", "side"].every((k) => typeof textures[k] === "string") || + ["front", "side"].every((k) => typeof textures[k] === "string"))); + if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }"); + for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) { + if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`); + } + if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) { + problems.push("alpha must be between 0 and 1"); + } + if (json.mining !== undefined) { + if (!is_object(json.mining) || typeof json.mining.toughness !== "number" || json.mining.toughness < 0) { + problems.push("mining.toughness must be a number of seconds"); + } + } + if (json.drops !== undefined) problems.push(...validate_id(json.drops, "drops")); + if (json.states !== undefined) { + const states = json.states; + if (!Array.isArray(states)) { + problems.push("states must be a list"); + } else { + const bits = states.reduce((sum, s) => sum + (is_object(s) && typeof s.bits === "number" ? s.bits : 0), 0); + if (bits > 16) problems.push(`states use ${bits} bits, the most is 16`); + } + } + return problems; +} + +export function validate_item(json: unknown): Problems { + if (!is_object(json)) return ["item must be an object"]; + const problems = validate_id(json.id, "id"); + problems.push(...validate_id(json.texture, "texture")); + if (json.places !== undefined) problems.push(...validate_id(json.places, "places")); + if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) { + problems.push("max_stack must be a positive whole number"); + } + return problems; +} + +export function validate_recipe(json: unknown): Problems { + if (!is_object(json)) return ["recipe must be an object"]; + const problems: Problems = []; + switch (json.type) { + case "shaped": { + const pattern = json.pattern; + if ( + !Array.isArray(pattern) || pattern.length < 1 || pattern.length > 3 || + !pattern.every((row) => typeof row === "string" && row.length >= 1 && row.length <= 3) + ) { + problems.push("pattern must be 1-3 rows of 1-3 characters"); + } else if (is_object(json.key)) { + for (const letter of pattern.join("").replaceAll(" ", "")) { + if (!(letter in json.key)) problems.push(`pattern uses "${letter}" but key doesn't define it`); + } + } + if (!is_object(json.key)) { + problems.push("key must map letters to item ids"); + } else { + for (const id of Object.values(json.key)) problems.push(...validate_id(id, "key")); + } + problems.push(...validate_stack(json.result, "result")); + break; + } + case "furnace": + problems.push(...validate_id(json.input, "input"), ...validate_stack(json.output, "output")); + if (!Number.isInteger(json.cook_time) || (json.cook_time as number) < 1) { + problems.push("cook_time must be a positive number of ticks"); + } + break; + case "fuel": + problems.push(...validate_id(json.item, "item")); + if (!Number.isInteger(json.burn_time) || (json.burn_time as number) < 1) { + problems.push("burn_time must be a positive number of ticks"); + } + break; + default: + problems.push('type must be "shaped", "furnace" or "fuel"'); + } + 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"`]; +} + +function validate_stack(value: unknown, field: string): Problems { + if (!is_object(value)) return [`${field} must be { id, count }`]; + const problems = validate_id(value.id, `${field}.id`); + if (!Number.isInteger(value.count) || (value.count as number) < 1) { + problems.push(`${field}.count must be a positive whole number`); + } + return problems; +} + +function is_object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/deno.json b/deno.json index 79fda57..5fb7c95 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,11 @@ "tasks": { "build": "deno run -A build.ts", "serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build", - "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts" + "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": { "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] @@ -16,8 +20,12 @@ }, "imports": { "$/": "./", + "bworld/server": "./common/mod_api/server.ts", + "bworld/client": "./common/mod_api/client.ts", + "bworld/worldgen": "./common/mod_api/worldgen.ts", "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", "@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3", + "@std/assert": "jsr:@std/assert@^1.0.0", "@std/fs": "jsr:@std/fs@^1.0.23", "@std/http": "jsr:@std/http@^1.0.23", "gl-matrix": "npm:gl-matrix@^3.4.4", diff --git a/deno.lock b/deno.lock index 6020ca8..5a23245 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2", "jsr:@paulaboks/rng@^0.0.3": "0.0.3", + "jsr:@std/assert@1": "1.0.14", "jsr:@std/cli@^1.0.27": "1.0.27", "jsr:@std/encoding@1.0.5": "1.0.5", "jsr:@std/encoding@^1.0.10": "1.0.10", @@ -12,6 +13,7 @@ "jsr:@std/html@^1.0.5": "1.0.5", "jsr:@std/http@*": "1.0.24", "jsr:@std/http@^1.0.23": "1.0.24", + "jsr:@std/internal@^1.0.10": "1.0.12", "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", @@ -30,6 +32,12 @@ "@paulaboks/rng@0.0.3": { "integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202" }, + "@std/assert@1.0.14": { + "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] + }, "@std/cli@1.0.27": { "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de" }, @@ -45,7 +53,7 @@ "@std/fs@1.0.23": { "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.12", "jsr:@std/path" ] }, @@ -78,7 +86,7 @@ "@std/path@1.1.4": { "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" ] }, "@std/streams@1.1.2": { @@ -98,6 +106,7 @@ "dependencies": [ "jsr:@gfx/canvas-wasm@~0.4.2", "jsr:@paulaboks/rng@^0.0.3", + "jsr:@std/assert@1", "jsr:@std/fs@^1.0.23", "jsr:@std/http@^1.0.23", "npm:gl-matrix@^3.4.4", diff --git a/mods/bworld/CREDITS.md b/mods/bworld/CREDITS.md new file mode 100644 index 0000000..333d6ac --- /dev/null +++ b/mods/bworld/CREDITS.md @@ -0,0 +1,82 @@ +# bworld + +Textures and sprites by Kenney, from these CC0 packs: + +# Tiny town + +https://kenney.nl/assets/tiny-town + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# Roguelike + +https://kenney.nl/assets/roguelike-rpg-pack + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# UI + +https://kenney.nl/assets/ui-pack-pixel-adventure + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# m6x11.ttf + +Font by Daniel Linssen (https://managore.itch.io) + +https://managore.itch.io/m6x11 (https://web.archive.org/web/20260217032856/https://managore.itch.io/m6x11) + +license: `free to use with attribution` + +# Tiny wonder farm + +https://butterymilk.itch.io/tiny-wonder-farm-asset-pack +(https://web.archive.org/web/20260204234306/https://butterymilk.itch.io/tiny-wonder-farm-asset-pack) + +license: + +``` +There's two versions, free and paid (premium): + +Free: It includes a limited number of items available for free. + +You are free to use sprites included in the pack in any of your non-commercial projects as well as edit the sprites and +add something new! + +Premium: It includes everything shown from the showcase, all tilemaps, sprites, characters and more! + +You are free to use sprites included in the pack in any of your commercial or non-commercial projects as well as edit +the sprites and add something new! + +You can't resell the sprites, even if changes were made. You cannot use it in any projects related to NFT." +``` + +# Farmer's delight + +https://github.com/vectorwing/FarmersDelight + +License: + +``` +MIT License + +Copyright (c) 2020 vectorwing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/mods/bworld/README.md b/mods/bworld/README.md new file mode 100644 index 0000000..9735da7 --- /dev/null +++ b/mods/bworld/README.md @@ -0,0 +1,21 @@ +# bworld + +The base game as a mod, following "The base game as a mod" in `MODS.md`. + +**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: + +```sh +deno task export-bworld +``` + +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`. | diff --git a/mods/bworld/blocks/chest.json b/mods/bworld/blocks/chest.json new file mode 100644 index 0000000..f49da6b --- /dev/null +++ b/mods/bworld/blocks/chest.json @@ -0,0 +1,14 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:chest", + "textures": "bworld:planks", + "collision": false, + "mining": { + "toughness": 8, + "tool": "axe" + }, + "drops": "bworld:chest", + "interactive": true + } +} diff --git a/mods/bworld/blocks/coal_ore.json b/mods/bworld/blocks/coal_ore.json new file mode 100644 index 0000000..2379679 --- /dev/null +++ b/mods/bworld/blocks/coal_ore.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:coal_ore", + "textures": "bworld:stone_coal", + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:coal_ore" + } +} diff --git a/mods/bworld/blocks/copper_ore.json b/mods/bworld/blocks/copper_ore.json new file mode 100644 index 0000000..1308910 --- /dev/null +++ b/mods/bworld/blocks/copper_ore.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:copper_ore", + "textures": "bworld:stone_copper", + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:copper_ore" + } +} diff --git a/mods/bworld/blocks/dirt.json b/mods/bworld/blocks/dirt.json new file mode 100644 index 0000000..867fe01 --- /dev/null +++ b/mods/bworld/blocks/dirt.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:dirt", + "textures": "bworld:dirt", + "collision": false, + "mining": { + "toughness": 2, + "tool": "shovel" + }, + "drops": "bworld:dirt" + } +} diff --git a/mods/bworld/blocks/furnace.json b/mods/bworld/blocks/furnace.json new file mode 100644 index 0000000..19f670c --- /dev/null +++ b/mods/bworld/blocks/furnace.json @@ -0,0 +1,17 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:furnace", + "textures": { + "front": "bworld:furnace", + "side": "bworld:stone" + }, + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:furnace", + "interactive": true + } +} diff --git a/mods/bworld/blocks/glass.json b/mods/bworld/blocks/glass.json new file mode 100644 index 0000000..9213c37 --- /dev/null +++ b/mods/bworld/blocks/glass.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:glass", + "textures": "bworld:glass", + "transparent": true, + "mining": { + "toughness": 3, + "tool": "pickaxe" + } + } +} diff --git a/mods/bworld/blocks/gold_ore.json b/mods/bworld/blocks/gold_ore.json new file mode 100644 index 0000000..cec013c --- /dev/null +++ b/mods/bworld/blocks/gold_ore.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:gold_ore", + "textures": "bworld:stone_gold", + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:gold_ore" + } +} diff --git a/mods/bworld/blocks/grass.json b/mods/bworld/blocks/grass.json new file mode 100644 index 0000000..5bc531b --- /dev/null +++ b/mods/bworld/blocks/grass.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:grass", + "textures": { + "top": "bworld:grass_top", + "bottom": "bworld:dirt", + "side": "bworld:grass_side" + }, + "collision": false, + "mining": { + "toughness": 2, + "tool": "shovel" + }, + "drops": "bworld:dirt", + "item": false + } +} diff --git a/mods/bworld/blocks/hoed_dirt.json b/mods/bworld/blocks/hoed_dirt.json new file mode 100644 index 0000000..706aa66 --- /dev/null +++ b/mods/bworld/blocks/hoed_dirt.json @@ -0,0 +1,24 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:hoed_dirt", + "textures": { + "side": "bworld:dirt", + "top": "bworld:hoed_dirt", + "bottom": "bworld:dirt" + }, + "collision": false, + "mining": { + "toughness": 5, + "tool": "shovel" + }, + "drops": "bworld:dirt", + "states": [ + { + "name": "watered", + "bits": 1, + "default": 0 + } + ] + } +} diff --git a/mods/bworld/blocks/iron_ore.json b/mods/bworld/blocks/iron_ore.json new file mode 100644 index 0000000..032bafe --- /dev/null +++ b/mods/bworld/blocks/iron_ore.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:iron_ore", + "textures": "bworld:stone_iron", + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:iron_ore" + } +} diff --git a/mods/bworld/blocks/leaves.json b/mods/bworld/blocks/leaves.json new file mode 100644 index 0000000..0be99d1 --- /dev/null +++ b/mods/bworld/blocks/leaves.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:leaves", + "textures": "bworld:leaves", + "transparent": true, + "mining": { + "toughness": 3, + "tool": "hoe" + } + } +} diff --git a/mods/bworld/blocks/log.json b/mods/bworld/blocks/log.json new file mode 100644 index 0000000..6299611 --- /dev/null +++ b/mods/bworld/blocks/log.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:log", + "textures": { + "side": "bworld:log_side", + "top": "bworld:log_top", + "bottom": "bworld:log_top" + }, + "mining": { + "toughness": 3, + "tool": "axe" + }, + "drops": "bworld:log" + } +} diff --git a/mods/bworld/blocks/planks.json b/mods/bworld/blocks/planks.json new file mode 100644 index 0000000..f693ca8 --- /dev/null +++ b/mods/bworld/blocks/planks.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:planks", + "textures": "bworld:planks", + "mining": { + "toughness": 3, + "tool": "axe" + }, + "drops": "bworld:log" + } +} diff --git a/mods/bworld/blocks/sand.json b/mods/bworld/blocks/sand.json new file mode 100644 index 0000000..d9d367a --- /dev/null +++ b/mods/bworld/blocks/sand.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:sand", + "textures": "bworld:sand", + "mining": { + "toughness": 3, + "tool": "shovel" + }, + "drops": "bworld:sand" + } +} diff --git a/mods/bworld/blocks/snow.json b/mods/bworld/blocks/snow.json new file mode 100644 index 0000000..79800fe --- /dev/null +++ b/mods/bworld/blocks/snow.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:snow", + "textures": "bworld:snow", + "mining": { + "toughness": 2, + "tool": "shovel", + "requires_tool": true + } + } +} diff --git a/mods/bworld/blocks/stone.json b/mods/bworld/blocks/stone.json new file mode 100644 index 0000000..7e701a3 --- /dev/null +++ b/mods/bworld/blocks/stone.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:stone", + "textures": "bworld:stone", + "mining": { + "toughness": 3, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:stone" + } +} diff --git a/mods/bworld/blocks/tin_ore.json b/mods/bworld/blocks/tin_ore.json new file mode 100644 index 0000000..5e99e65 --- /dev/null +++ b/mods/bworld/blocks/tin_ore.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:tin_ore", + "textures": "bworld:stone_tin", + "mining": { + "toughness": 5, + "tool": "pickaxe", + "requires_tool": true + }, + "drops": "bworld:tin_ore" + } +} diff --git a/mods/bworld/blocks/water.json b/mods/bworld/blocks/water.json new file mode 100644 index 0000000..911eeb4 --- /dev/null +++ b/mods/bworld/blocks/water.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:water", + "textures": "bworld:water", + "transparent": true, + "alpha": 0.8, + "collision": false, + "item": false, + "replaceable": true + } +} diff --git a/mods/bworld/items/axe.json b/mods/bworld/items/axe.json new file mode 100644 index 0000000..dfee3f4 --- /dev/null +++ b/mods/bworld/items/axe.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:axe", + "texture": "bworld:axe", + "tool": "axe" + } +} diff --git a/mods/bworld/items/coal.json b/mods/bworld/items/coal.json new file mode 100644 index 0000000..cf6c1ac --- /dev/null +++ b/mods/bworld/items/coal.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:coal", + "texture": "bworld:coal" + } +} diff --git a/mods/bworld/items/copper_ingot.json b/mods/bworld/items/copper_ingot.json new file mode 100644 index 0000000..5965205 --- /dev/null +++ b/mods/bworld/items/copper_ingot.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:copper_ingot", + "texture": "bworld:copper_ingot" + } +} diff --git a/mods/bworld/items/gold_ingot.json b/mods/bworld/items/gold_ingot.json new file mode 100644 index 0000000..4656651 --- /dev/null +++ b/mods/bworld/items/gold_ingot.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:gold_ingot", + "texture": "bworld:gold_ingot" + } +} diff --git a/mods/bworld/items/hoe.json b/mods/bworld/items/hoe.json new file mode 100644 index 0000000..a745f42 --- /dev/null +++ b/mods/bworld/items/hoe.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:hoe", + "texture": "bworld:hoe" + } +} diff --git a/mods/bworld/items/iron_ingot.json b/mods/bworld/items/iron_ingot.json new file mode 100644 index 0000000..028eaad --- /dev/null +++ b/mods/bworld/items/iron_ingot.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:iron_ingot", + "texture": "bworld:iron_ingot" + } +} diff --git a/mods/bworld/items/pickaxe.json b/mods/bworld/items/pickaxe.json new file mode 100644 index 0000000..4fb0c77 --- /dev/null +++ b/mods/bworld/items/pickaxe.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:pickaxe", + "texture": "bworld:pickaxe" + } +} diff --git a/mods/bworld/items/stick.json b/mods/bworld/items/stick.json new file mode 100644 index 0000000..7eb550c --- /dev/null +++ b/mods/bworld/items/stick.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:stick", + "texture": "bworld:stick" + } +} diff --git a/mods/bworld/items/tin_ingot.json b/mods/bworld/items/tin_ingot.json new file mode 100644 index 0000000..97d2905 --- /dev/null +++ b/mods/bworld/items/tin_ingot.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:tin_ingot", + "texture": "bworld:tin_ingot" + } +} diff --git a/mods/bworld/items/watering_can.json b/mods/bworld/items/watering_can.json new file mode 100644 index 0000000..c24815b --- /dev/null +++ b/mods/bworld/items/watering_can.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:watering_can", + "texture": "bworld:watering_can" + } +} diff --git a/mods/bworld/items/wood_pickaxe.json b/mods/bworld/items/wood_pickaxe.json new file mode 100644 index 0000000..ebb6616 --- /dev/null +++ b/mods/bworld/items/wood_pickaxe.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:wood_pickaxe", + "texture": "bworld:wood_pickaxe", + "tool": "pickaxe" + } +} diff --git a/mods/bworld/manifest.json b/mods/bworld/manifest.json new file mode 100644 index 0000000..417809e --- /dev/null +++ b/mods/bworld/manifest.json @@ -0,0 +1,10 @@ +{ + "format_version": 1, + "id": "bworld", + "name": "bworld", + "description": "The base game: its blocks, items, recipes and world.", + "version": "0.1.0", + "authors": ["paula"], + "game_version": ">=0.1.0", + "credits": "CREDITS.md" +} diff --git a/mods/bworld/recipes/crafting_chest.json b/mods/bworld/recipes/crafting_chest.json new file mode 100644 index 0000000..1b7f572 --- /dev/null +++ b/mods/bworld/recipes/crafting_chest.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": [ + "PPP", + "P P", + "PPP" + ], + "key": { + "P": "bworld:planks" + }, + "result": { + "id": "bworld:chest", + "count": 1 + } + } +} diff --git a/mods/bworld/recipes/crafting_furnace.json b/mods/bworld/recipes/crafting_furnace.json new file mode 100644 index 0000000..5e355a6 --- /dev/null +++ b/mods/bworld/recipes/crafting_furnace.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": [ + "SSS", + "S S", + "SSS" + ], + "key": { + "S": "bworld:stone" + }, + "result": { + "id": "bworld:furnace", + "count": 1 + } + } +} diff --git a/mods/bworld/recipes/crafting_planks.json b/mods/bworld/recipes/crafting_planks.json new file mode 100644 index 0000000..82eacb2 --- /dev/null +++ b/mods/bworld/recipes/crafting_planks.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": [ + "L" + ], + "key": { + "L": "bworld:log" + }, + "result": { + "id": "bworld:planks", + "count": 2 + } + } +} diff --git a/mods/bworld/recipes/crafting_stick.json b/mods/bworld/recipes/crafting_stick.json new file mode 100644 index 0000000..8b4fa38 --- /dev/null +++ b/mods/bworld/recipes/crafting_stick.json @@ -0,0 +1,17 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": [ + "P", + "P" + ], + "key": { + "P": "bworld:planks" + }, + "result": { + "id": "bworld:stick", + "count": 2 + } + } +} diff --git a/mods/bworld/recipes/crafting_wood_pickaxe.json b/mods/bworld/recipes/crafting_wood_pickaxe.json new file mode 100644 index 0000000..625f0ae --- /dev/null +++ b/mods/bworld/recipes/crafting_wood_pickaxe.json @@ -0,0 +1,19 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": [ + "PPP", + " S ", + " S " + ], + "key": { + "P": "bworld:planks", + "S": "bworld:stick" + }, + "result": { + "id": "bworld:wood_pickaxe", + "count": 1 + } + } +} diff --git a/mods/bworld/recipes/fuel_coal.json b/mods/bworld/recipes/fuel_coal.json new file mode 100644 index 0000000..a03706e --- /dev/null +++ b/mods/bworld/recipes/fuel_coal.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "recipe": { + "type": "fuel", + "item": "bworld:coal", + "burn_time": 1000 + } +} diff --git a/mods/bworld/recipes/fuel_log.json b/mods/bworld/recipes/fuel_log.json new file mode 100644 index 0000000..52283b9 --- /dev/null +++ b/mods/bworld/recipes/fuel_log.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "recipe": { + "type": "fuel", + "item": "bworld:log", + "burn_time": 100 + } +} diff --git a/mods/bworld/recipes/smelting_coal_ore.json b/mods/bworld/recipes/smelting_coal_ore.json new file mode 100644 index 0000000..3467b75 --- /dev/null +++ b/mods/bworld/recipes/smelting_coal_ore.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:coal_ore", + "output": { + "id": "bworld:coal", + "count": 1 + }, + "cook_time": 100 + } +} diff --git a/mods/bworld/recipes/smelting_copper_ore.json b/mods/bworld/recipes/smelting_copper_ore.json new file mode 100644 index 0000000..e030ff2 --- /dev/null +++ b/mods/bworld/recipes/smelting_copper_ore.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:copper_ore", + "output": { + "id": "bworld:copper_ingot", + "count": 1 + }, + "cook_time": 200 + } +} diff --git a/mods/bworld/recipes/smelting_gold_ore.json b/mods/bworld/recipes/smelting_gold_ore.json new file mode 100644 index 0000000..9f7e71f --- /dev/null +++ b/mods/bworld/recipes/smelting_gold_ore.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:gold_ore", + "output": { + "id": "bworld:gold_ingot", + "count": 1 + }, + "cook_time": 200 + } +} diff --git a/mods/bworld/recipes/smelting_iron_ore.json b/mods/bworld/recipes/smelting_iron_ore.json new file mode 100644 index 0000000..519caf4 --- /dev/null +++ b/mods/bworld/recipes/smelting_iron_ore.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:iron_ore", + "output": { + "id": "bworld:iron_ingot", + "count": 1 + }, + "cook_time": 200 + } +} diff --git a/mods/bworld/recipes/smelting_log.json b/mods/bworld/recipes/smelting_log.json new file mode 100644 index 0000000..cd859c5 --- /dev/null +++ b/mods/bworld/recipes/smelting_log.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:log", + "output": { + "id": "bworld:coal", + "count": 1 + }, + "cook_time": 100 + } +} diff --git a/mods/bworld/recipes/smelting_tin_ore.json b/mods/bworld/recipes/smelting_tin_ore.json new file mode 100644 index 0000000..39358de --- /dev/null +++ b/mods/bworld/recipes/smelting_tin_ore.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "bworld:tin_ore", + "output": { + "id": "bworld:tin_ingot", + "count": 1 + }, + "cook_time": 200 + } +} diff --git a/server/game/blocks.ts b/server/game/blocks.ts index 9483614..5c1655b 100644 --- a/server/game/blocks.ts +++ b/server/game/blocks.ts @@ -80,13 +80,13 @@ BLOCK_BEHAVIORS["bworld:chest"] = { // furnace -interface FurnaceRecipe { +export interface FurnaceRecipe { input: string; output: ItemStack; cook_time: number; } -const FURNACE_RECIPES: FurnaceRecipe[] = [ +export const FURNACE_RECIPES: FurnaceRecipe[] = [ { input: "bworld:log", output: new ItemStack("bworld:coal", 1), @@ -119,7 +119,7 @@ const FURNACE_RECIPES: FurnaceRecipe[] = [ }, ]; -const FUEL_VALUES: Record = { +export const FUEL_VALUES: Record = { "bworld:coal": 1000, "bworld:log": 100, }; diff --git a/server/game/crafting.ts b/server/game/crafting.ts index f284c66..73b60d5 100644 --- a/server/game/crafting.ts +++ b/server/game/crafting.ts @@ -8,7 +8,7 @@ export interface CraftingRecipe { result: { id: string; count: number }; } -const recipes: CraftingRecipe[] = [ +export const CRAFTING_RECIPES: CraftingRecipe[] = [ { width: 3, height: 3, @@ -117,7 +117,7 @@ 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) { const grid = get_crafting_grid(crafting); - const recipe = recipes.find((recipe) => matches_recipe(grid, recipe)); + const recipe = CRAFTING_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/templates/mod/CREDITS.md b/templates/mod/CREDITS.md new file mode 100644 index 0000000..b21292c --- /dev/null +++ b/templates/mod/CREDITS.md @@ -0,0 +1,3 @@ +# Example Mod + +Made by you. List where textures and other assets come from and their licenses here. diff --git a/templates/mod/README.md b/templates/mod/README.md new file mode 100644 index 0000000..f59fa25 --- /dev/null +++ b/templates/mod/README.md @@ -0,0 +1,31 @@ +# Example Mod + +A bworld mod. The format is described in `MODS.md` at the root of the bworld repo. + +## Files + +| Path | What it's for | +| --------------------- | ------------------------------------------------------------------------------------ | +| `manifest.json` | The mod's id, name, version, dependencies and scripts. | +| `blocks/*.json` | One block per file. Blocks get an item form with the same id unless `"item": false`. | +| `items/*.json` | Items that aren't a block's item form. | +| `recipes/*.json` | `shaped` crafting, `furnace` smelting and `fuel`. | +| `textures/*.png` | 16×16 textures, named `example_mod:`. | +| `worldgen/ores.json` | Ores to generate (optional, not included here). | +| `scripts/server.ts` | Game logic: block components, commands, events. Never sent to players. | +| `scripts/client.ts` | Sent to players: custom screens, HUD, keybinds. | +| `scripts/worldgen.ts` | Terrain features. Runs on the server and every client and must be deterministic. | +| `CREDITS.md` | Licenses and thanks, shown on the About page. | + +Delete what you don't use, including its entry in `manifest.json`. + +## Checking it + +From the bworld repo: + +```sh +deno task check-mods +``` + +This validates the manifest and every data file, checks that the ids, textures and items you refer to exist, and +typechecks the scripts. diff --git a/templates/mod/blocks/example_block.json b/templates/mod/blocks/example_block.json new file mode 100644 index 0000000..4d0d503 --- /dev/null +++ b/templates/mod/blocks/example_block.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "block": { + "id": "example_mod:example_block", + "textures": "example_mod:example_block", + "mining": { "toughness": 3, "tool": "pickaxe", "requires_tool": false }, + "drops": "example_mod:example_block", + "interactive": true, + "components": { + "example_mod:announce": { "message": "You found the example block!" } + } + } +} diff --git a/templates/mod/items/example_item.json b/templates/mod/items/example_item.json new file mode 100644 index 0000000..92032f6 --- /dev/null +++ b/templates/mod/items/example_item.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "item": { + "id": "example_mod:example_item", + "texture": "example_mod:example_item", + "lore": "Four of these make an example block." + } +} diff --git a/templates/mod/manifest.json b/templates/mod/manifest.json new file mode 100644 index 0000000..43ea384 --- /dev/null +++ b/templates/mod/manifest.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "id": "example_mod", + "name": "Example Mod", + "description": "What this mod adds, in a sentence or two.", + "version": "0.1.0", + "authors": [], + "game_version": ">=0.1.0", + "dependencies": [ + { "id": "bworld", "version": ">=0.1.0" } + ], + "scripts": { + "server": "scripts/server.ts", + "client": "scripts/client.ts", + "worldgen": "scripts/worldgen.ts" + }, + "credits": "CREDITS.md" +} diff --git a/templates/mod/recipes/example_block.json b/templates/mod/recipes/example_block.json new file mode 100644 index 0000000..1fa781f --- /dev/null +++ b/templates/mod/recipes/example_block.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "recipe": { + "type": "shaped", + "pattern": ["EE", "EE"], + "key": { "E": "example_mod:example_item" }, + "result": { "id": "example_mod:example_block", "count": 1 } + } +} diff --git a/templates/mod/scripts/client.ts b/templates/mod/scripts/client.ts new file mode 100644 index 0000000..25d8ef1 --- /dev/null +++ b/templates/mod/scripts/client.ts @@ -0,0 +1,14 @@ +import type { ClientContext } from "bworld/client"; + +// called on every player's client after the mod is downloaded, before the world shows up. +// everything here is visible to players, so keep secrets and game rules in server.ts +export function setup(ctx: ClientContext) { + ctx.log("loaded"); + + // a HUD element, drawn every frame + // ctx.hud.register("example_mod:hint", { + // on_render(g) { + // g.text("example mod is here", 8, 8); + // }, + // }); +} diff --git a/templates/mod/scripts/server.ts b/templates/mod/scripts/server.ts new file mode 100644 index 0000000..baf07e1 --- /dev/null +++ b/templates/mod/scripts/server.ts @@ -0,0 +1,26 @@ +import type { ServerContext } from "bworld/server"; + +// called once when the server starts +export function setup(ctx: ServerContext) { + // used by blocks/example_block.json, which passes { message } as params + ctx.components.register_block<{ message: string }>("example_mod:announce", { + on_interact(_block, params, player) { + player.send_message(params.message); + // handled, so right clicking it doesn't place a block + return true; + }, + }); + + // /example_mod:hello, or /hello when no other mod uses that name + ctx.commands.register("hello", { + description: "Say hello", + usage: "/hello", + run(_args, player) { + player.send_message(`Hello ${player.name}!`); + }, + }); + + ctx.events.after.player_join.subscribe(({ player }) => { + ctx.log(`${player.name} joined`); + }); +} diff --git a/templates/mod/scripts/worldgen.ts b/templates/mod/scripts/worldgen.ts new file mode 100644 index 0000000..35d5b3f --- /dev/null +++ b/templates/mod/scripts/worldgen.ts @@ -0,0 +1,15 @@ +import type { WorldgenContext } from "bworld/worldgen"; + +// runs in chunk workers on the server and every client, which must all generate the same world. +// only use chunk.rng and the noise helpers, never Math.random or the time +export function setup(gen: WorldgenContext) { + // a boulder on one chunk in ten + gen.register_feature("example_mod:boulders", (chunk) => { + if (chunk.rng.next() > 0.1) { + return; + } + const x = chunk.x * 16 + Math.floor(chunk.rng.next() * 16); + const z = chunk.z * 16 + Math.floor(chunk.rng.next() * 16); + chunk.set_block(x, chunk.height_at(x, z) + 1, z, "bworld:stone"); + }); +} diff --git a/templates/mod/textures/example_block.png b/templates/mod/textures/example_block.png new file mode 100644 index 0000000000000000000000000000000000000000..ff236cd9bf144549a0d1dfa5a33726f1677c3c99 GIT binary patch literal 201 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Sc;uILpXq- zh9ji|$S?MEaSXBOO}?^f{jc*5j0uSe2?+(f{Horc-%VYu$YuZpF*~Z;b)08>@Vpnj zbK1jur?x(kEXaQv#kTZA0toMEdtl g{}*h%Og;<@t{!qbMc&!J1zO4A>FVdQ&MBb@03{+m-v9sr literal 0 HcmV?d00001 diff --git a/tests/bworld_mod_test.ts b/tests/bworld_mod_test.ts new file mode 100644 index 0000000..3163500 --- /dev/null +++ b/tests/bworld_mod_test.ts @@ -0,0 +1,65 @@ +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/mod_tools_test.ts b/tests/mod_tools_test.ts new file mode 100644 index 0000000..839256d --- /dev/null +++ b/tests/mod_tools_test.ts @@ -0,0 +1,103 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { check_mods } from "$/tools/check_mods.ts"; +import { create_mod } from "$/tools/new_mod.ts"; + +function temp_mods_dir() { + const dir = Deno.makeTempDirSync({ prefix: "bworld_mods_" }); + // most mods depend on the base game + Deno.mkdirSync(`${dir}/bworld`); + for (const entry of ["manifest.json", "blocks", "items", "recipes"]) { + copy(`mods/bworld/${entry}`, `${dir}/bworld/${entry}`); + } + return dir; +} + +function copy(from: string, to: string) { + if (Deno.statSync(from).isDirectory) { + Deno.mkdirSync(to, { recursive: true }); + for (const entry of Deno.readDirSync(from)) copy(`${from}/${entry.name}`, `${to}/${entry.name}`); + } else { + Deno.copyFileSync(from, to); + } +} + +function write_json(path: string, value: unknown) { + Deno.writeTextFileSync(path, JSON.stringify(value)); +} + +Deno.test("a mod made from the template passes the checks", async () => { + const dir = temp_mods_dir(); + create_mod("copper_tools", "Copper Tools", dir); + const manifest = JSON.parse(Deno.readTextFileSync(`${dir}/copper_tools/manifest.json`)); + assertEquals(manifest.id, "copper_tools"); + assertEquals(manifest.name, "Copper Tools"); + assert(!Deno.readTextFileSync(`${dir}/copper_tools/blocks/example_block.json`).includes("example_mod")); + + const report = (await check_mods(dir)).find((r) => r.id === "copper_tools")!; + assertEquals(report.errors, []); + assertEquals(report.warnings, []); + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("new-mod rejects bad and reserved ids", () => { + const dir = temp_mods_dir(); + assertThrows(() => create_mod("Has Spaces", "x", dir)); + assertThrows(() => create_mod("bworld", "x", dir)); + assertThrows(() => create_mod("engine", "x", dir)); + create_mod("once", "x", dir); + assertThrows(() => create_mod("once", "x", dir)); + Deno.removeSync(dir, { recursive: true }); +}); + +Deno.test("check-mods finds broken mods", async () => { + const dir = temp_mods_dir(); + create_mod("broken", "Broken", dir); + const mod = `${dir}/broken`; + + // wrong namespace, a drop that doesn't exist, and a texture that doesn't exist + write_json(`${mod}/blocks/wrong.json`, { + format_version: 1, + block: { id: "other:thing", textures: "broken:nope", drops: "broken:no_such_item" }, + }); + // defines a block the base game already has + write_json(`${mod}/blocks/dupe.json`, { format_version: 1, block: { id: "broken:x", textures: "bworld:stone" } }); + write_json(`${mod}/blocks/stone_copy.json`, { + format_version: 1, + block: { id: "broken:x", textures: "bworld:stone" }, + }); + // not json + Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope"); + // uses a letter the key doesn't define + write_json(`${mod}/recipes/bad.json`, { + format_version: 1, + recipe: { + type: "shaped", + pattern: ["AB"], + key: { A: "bworld:stone" }, + result: { id: "bworld:stone", count: 1 }, + }, + }); + // wrong texture size + Deno.copyFileSync("assets/sprites/ui.png", `${mod}/textures/huge.png`); + // a script that doesn't typecheck + Deno.writeTextFileSync(`${mod}/scripts/client.ts`, "export function setup(ctx: number) { ctx.nope(); }\n"); + // a dependency that isn't installed, and no longer depending on bworld + const manifest = JSON.parse(Deno.readTextFileSync(`${mod}/manifest.json`)); + manifest.dependencies = [{ id: "missing_mod", version: "1.0.0" }]; + write_json(`${mod}/manifest.json`, manifest); + + const report = (await check_mods(dir)).find((r) => r.id === "broken")!; + const has = (list: string[], text: string) => + assert(list.some((e) => e.includes(text)), `expected "${text}" in\n${list.join("\n")}`); + has(report.errors, `other:thing isn't in this mod's namespace`); + has(report.errors, "item broken:no_such_item doesn't exist"); + has(report.errors, "block broken:x is also defined by broken, broken"); + has(report.errors, "items/bad.json: isn't valid json"); + has(report.errors, `pattern uses "B" but key doesn't define it`); + has(report.errors, "textures/huge.png: is"); + has(report.errors, "scripts don't typecheck"); + has(report.errors, `depends on "missing_mod", which isn't installed`); + has(report.warnings, "texture broken:nope doesn't exist"); + has(report.warnings, `uses bworld:stone but doesn't list "bworld" in dependencies`); + Deno.removeSync(dir, { recursive: true }); +}); diff --git a/tools/check_mods.ts b/tools/check_mods.ts new file mode 100644 index 0000000..320b709 --- /dev/null +++ b/tools/check_mods.ts @@ -0,0 +1,295 @@ +// deno task check-mods +// validates every mod in mods/: manifests, data files, textures, references between them, and typechecks scripts +import { + BlockJson, + FORMAT_VERSION, + ItemJson, + RecipeJson, + validate_block, + validate_item, + validate_manifest, + validate_recipe, +} from "$/common/mod_data.ts"; + +export interface ModReport { + id: string; + errors: string[]; + warnings: string[]; +} + +interface LoadedMod { + id: string; + dir: string; + report: ModReport; + manifest?: Record; + blocks: { file: string; json: BlockJson }[]; + items: { file: string; json: ItemJson }[]; + recipes: { file: string; json: RecipeJson }[]; + textures: string[]; +} + +// 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"; + +export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise { + 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); + + if (options.typecheck) { + for (const mod of mods) { + await typecheck_scripts(mod); + } + } + + return mods.map((mod) => mod.report); +} + +function load_mod(mods_dir: string, id: string): LoadedMod { + const dir = `${mods_dir}/${id}`; + const mod: LoadedMod = { + id, + dir, + report: { id, errors: [], warnings: [] }, + blocks: [], + items: [], + recipes: [], + textures: [], + }; + const error = (message: string) => mod.report.errors.push(message); + + const manifest = read_json(`${dir}/manifest.json`, error); + if (manifest !== undefined) { + for (const problem of validate_manifest(manifest, id)) error(`manifest.json: ${problem}`); + mod.manifest = manifest as Record; + } + if (id === "engine") { + error(`"engine" is reserved for the engine itself`); + } + + const scripts = (mod.manifest?.scripts ?? {}) as Record; + for (const [side, path] of Object.entries(scripts)) { + if (typeof path === "string" && !exists(`${dir}/${path}`)) { + error(`manifest.json: scripts.${side} ${path} doesn't exist`); + } + } + const credits = mod.manifest?.credits; + if (typeof credits === "string" && !exists(`${dir}/${credits}`)) { + error(`manifest.json: credits ${credits} doesn't exist`); + } + + const load_data = ( + folder: string, + key: string, + validate: (json: unknown) => string[], + into: { file: string; json: T }[], + ) => { + for (const file of walk(`${dir}/${folder}`, ".json")) { + const path = `${folder}/${file}`; + const wrapper = read_json(`${dir}/${path}`, (m) => error(`${path}: ${m}`)); + if (wrapper === undefined) continue; + if (!is_object(wrapper) || wrapper.format_version !== FORMAT_VERSION || !(key in wrapper)) { + error(`${path}: must be { "format_version": ${FORMAT_VERSION}, "${key}": { ... } }`); + continue; + } + const problems = validate(wrapper[key]); + for (const problem of problems) error(`${path}: ${problem}`); + if (problems.length === 0) into.push({ file: path, json: wrapper[key] as T }); + } + }; + load_data("blocks", "block", validate_block, mod.blocks); + load_data("items", "item", validate_item, mod.items); + load_data("recipes", "recipe", validate_recipe, mod.recipes); + + // 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}"`); + } + + for (const file of walk(`${dir}/textures`, ".png")) { + const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`; + mod.textures.push(texture_id); + const size = png_size(`${dir}/textures/${file}`); + if (!size) { + error(`textures/${file}: isn't a png`); + } else if (size.width !== 16 || size.height !== 16) { + error(`textures/${file}: is ${size.width}×${size.height}, textures must be 16×16`); + } + } + + return mod; +} + +function check_references(mods: LoadedMod[]) { + const block_owners = new Map(); + const item_owners = new Map(); + 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 mod of mods) { + for (const { json } of mod.blocks) { + add(block_owners, json.id, mod.id); + if (json.item !== false) add(item_owners, json.id, mod.id); + } + for (const { json } of mod.items) add(item_owners, json.id, mod.id); + for (const texture of mod.textures) textures.add(texture); + } + + const mod_ids = new Set(mods.map((mod) => mod.id)); + + for (const mod of mods) { + const { errors, warnings } = mod.report; + const dependencies = new Set( + ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((dep) => dep.id), + ); + for (const dep of dependencies) { + if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`); + } + + const uses = (file: string, id: string, what: "block" | "item" | "texture") => { + const namespace = id.split(":")[0]; + if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) { + warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`); + } + if (what === "texture") { + if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`); + } else if (!(what === "block" ? block_owners : item_owners).has(id)) { + errors.push(`${file}: ${what} ${id} doesn't exist`); + } + }; + + for (const { file, json } of mod.blocks) { + if ((block_owners.get(json.id)?.length ?? 0) > 1) { + errors.push(`${file}: block ${json.id} is also defined by ${block_owners.get(json.id)!.join(", ")}`); + } + const texture_ids = typeof json.textures === "string" ? [json.textures] : Object.values(json.textures); + for (const texture of texture_ids) uses(file, texture, "texture"); + if (json.drops) uses(file, json.drops, "item"); + } + for (const { file, json } of mod.items) { + if ((item_owners.get(json.id)?.length ?? 0) > 1) { + errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`); + } + uses(file, json.texture, "texture"); + if (json.places) uses(file, json.places, "block"); + } + for (const { file, json } of mod.recipes) { + switch (json.type) { + case "shaped": + for (const id of Object.values(json.key)) uses(file, id, "item"); + uses(file, json.result.id, "item"); + break; + case "furnace": + uses(file, json.input, "item"); + uses(file, json.output.id, "item"); + break; + case "fuel": + uses(file, json.item, "item"); + break; + } + } + } +} + +async function typecheck_scripts(mod: LoadedMod) { + const scripts = Object.values((mod.manifest?.scripts ?? {}) as Record) + .filter((path) => typeof path === "string" && exists(`${mod.dir}/${path}`)) + .map((path) => `${mod.dir}/${path}`); + if (scripts.length === 0) return; + + const output = await new Deno.Command(Deno.execPath(), { + args: ["check", "--config", "deno.json", ...scripts], + stdout: "piped", + stderr: "piped", + env: { NO_COLOR: "1" }, + }).output(); + if (!output.success) { + const text = new TextDecoder().decode(output.stderr).trim(); + mod.report.errors.push(`scripts don't typecheck:\n${text}`); + } +} + +// helpers + +function read_json(path: string, error: (message: string) => void): unknown { + let text: string; + try { + text = Deno.readTextFileSync(path); + } catch { + error(`${path.split("/").pop()} is missing`); + return undefined; + } + try { + return JSON.parse(text); + } catch (e) { + error(`isn't valid json: ${(e as Error).message}`); + return undefined; + } +} + +// relative paths of files ending in `extension`, in subfolders too +function walk(dir: string, extension: string, prefix = ""): string[] { + const files: string[] = []; + for (const entry of safe_read_dir(dir)) { + if (entry.isDirectory) { + files.push(...walk(`${dir}/${entry.name}`, extension, `${prefix}${entry.name}/`)); + } else if (entry.name.endsWith(extension)) { + files.push(`${prefix}${entry.name}`); + } + } + return files.sort(); +} + +function safe_read_dir(dir: string): Deno.DirEntry[] { + try { + return [...Deno.readDirSync(dir)].sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return []; + } +} + +function exists(path: string) { + try { + Deno.statSync(path); + return true; + } catch { + return false; + } +} + +// reads the size out of the png header instead of decoding the image +function png_size(path: string): { width: number; height: number } | undefined { + const bytes = Deno.readFileSync(path); + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (bytes.length < 24 || !signature.every((b, i) => bytes[i] === b)) return undefined; + const view = new DataView(bytes.buffer, bytes.byteOffset); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +function is_object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +if (import.meta.main) { + const reports = await check_mods(); + let errors = 0; + for (const report of reports) { + const status = report.errors.length ? "✗" : "✓"; + console.log(`${status} ${report.id}`); + for (const error of report.errors) console.log(` error: ${error.replaceAll("\n", "\n ")}`); + for (const warning of report.warnings) console.log(` warning: ${warning}`); + errors += report.errors.length; + } + if (reports.length === 0) console.log("No mods in mods/"); + Deno.exit(errors ? 1 : 0); +} diff --git a/tools/export_bworld_mod.ts b/tools/export_bworld_mod.ts new file mode 100644 index 0000000..78343af --- /dev/null +++ b/tools/export_bworld_mod.ts @@ -0,0 +1,85 @@ +// 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}`, + ); +} diff --git a/tools/new_mod.ts b/tools/new_mod.ts new file mode 100644 index 0000000..1783973 --- /dev/null +++ b/tools/new_mod.ts @@ -0,0 +1,58 @@ +// deno task new-mod ["Display Name"] +// copies templates/mod into mods/, with the template's placeholder id and name replaced +import { NAMESPACE_PATTERN, RESERVED_NAMESPACES } from "$/common/mod_data.ts"; + +const TEMPLATE = "templates/mod"; +const PLACEHOLDER_ID = "example_mod"; +const PLACEHOLDER_NAME = "Example Mod"; +const TEXT_FILES = /\.(json|md|ts|js)$/; + +export function create_mod(id: string, name: string, mods_dir = "mods") { + if (!NAMESPACE_PATTERN.test(id)) { + throw new Error(`"${id}" isn't a valid mod id: use 1-32 characters of a-z, 0-9 and _`); + } + if (RESERVED_NAMESPACES.includes(id)) { + throw new Error(`"${id}" is reserved`); + } + const target = `${mods_dir}/${id}`; + try { + Deno.statSync(target); + throw new Error(`${target} already exists`); + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + copy_dir(TEMPLATE, target, (text) => text.replaceAll(PLACEHOLDER_ID, id).replaceAll(PLACEHOLDER_NAME, name)); + return target; +} + +function copy_dir(from: string, to: string, transform: (text: string) => string) { + Deno.mkdirSync(to, { recursive: true }); + for (const entry of Deno.readDirSync(from)) { + const source = `${from}/${entry.name}`; + const destination = `${to}/${entry.name}`; + if (entry.isDirectory) { + copy_dir(source, destination, transform); + } else if (TEXT_FILES.test(entry.name)) { + Deno.writeTextFileSync(destination, transform(Deno.readTextFileSync(source))); + } else { + Deno.copyFileSync(source, destination); + } + } +} + +if (import.meta.main) { + const [id, name] = Deno.args; + if (!id) { + console.error('Usage: deno task new-mod ["Display Name"]'); + Deno.exit(1); + } + const display_name = name ?? id.split("_").map((word) => word[0].toUpperCase() + word.slice(1)).join(" "); + try { + const path = create_mod(id, display_name); + console.log(`Created ${path}. Edit its manifest.json, then run deno task check-mods.`); + console.log("Note: the game can't load mods yet, see the implementation plan in MODS.md."); + } catch (e) { + console.error((e as Error).message); + Deno.exit(1); + } +}