Test mod
This commit is contained in:
@@ -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 `<mod_id>: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
|
||||
|
||||
Reference in New Issue
Block a user