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
|
||||
|
||||
@@ -6,4 +6,5 @@ EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
|
||||
has_collision: false,
|
||||
transparent: true,
|
||||
alpha: 0.8,
|
||||
replaceable: true,
|
||||
});
|
||||
|
||||
@@ -48,6 +48,12 @@ export class EverythingRegistry {
|
||||
static get_registry<T>(registry: string): T[] {
|
||||
return this.#id_to_value.get(registry) as T[];
|
||||
}
|
||||
|
||||
// [key, value] pairs in registration order
|
||||
static entries<T>(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[];
|
||||
}
|
||||
|
||||
@@ -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<Position> };
|
||||
// read only, what this client sees
|
||||
world: { get_block(x: number, y: number, z: number): Id | undefined };
|
||||
log(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export interface ModScreen<Props = unknown> {
|
||||
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<Props>(id: Id, create: (props: Props) => ModScreen<Props>): void;
|
||||
// open a screen that doesn't involve the server, like a settings page
|
||||
open<Props>(id: Id, props: Props): void;
|
||||
}
|
||||
|
||||
export interface HudRegistry {
|
||||
register(id: Id, element: { on_render(g: Graphics): void }): void;
|
||||
}
|
||||
|
||||
export interface ClientNet {
|
||||
on<T = unknown>(channel: Id, handler: (data: T) => void): void;
|
||||
send(channel: Id, data: unknown): void;
|
||||
}
|
||||
@@ -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<P = any> {
|
||||
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<P = any> {
|
||||
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<P>(id: Id, component: BlockComponent<P>): void;
|
||||
register_item<P>(id: Id, component: ItemComponent<P>): 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<BlockBreakEvent & Cancelable>;
|
||||
block_place: EventSignal<BlockPlaceEvent & Cancelable>;
|
||||
block_interact: EventSignal<BlockInteractEvent & Cancelable>;
|
||||
chat_send: EventSignal<ChatSendEvent & Cancelable>;
|
||||
}
|
||||
|
||||
export interface ServerAfterEvents {
|
||||
block_break: EventSignal<BlockBreakEvent>;
|
||||
block_place: EventSignal<BlockPlaceEvent>;
|
||||
block_interact: EventSignal<BlockInteractEvent>;
|
||||
chat_send: EventSignal<Readonly<ChatSendEvent>>;
|
||||
player_join: EventSignal<{ readonly player: Player }>;
|
||||
player_leave: EventSignal<{ readonly player: Player }>;
|
||||
server_start: EventSignal<Record<never, never>>;
|
||||
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<T>(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<Position>;
|
||||
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<T>(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<T> = ({ canceled: true } & Partial<T>) | ({ 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<Props = unknown> {
|
||||
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<FormResult<{ selection: 0 | 1 }>>;
|
||||
action_form(player: Player, form: ActionForm): Promise<FormResult<{ selection: number }>>;
|
||||
modal_form(player: Player, form: ModalForm): Promise<FormResult<{ values: (boolean | number | string)[] }>>;
|
||||
open_container(player: Player, options: ContainerScreenOptions): ScreenHandle;
|
||||
open_screen<Props>(
|
||||
player: Player,
|
||||
id: Id,
|
||||
props: Props,
|
||||
options?: { containers?: Record<string, Container> },
|
||||
): ScreenHandle<Props>;
|
||||
}
|
||||
|
||||
// mod channels
|
||||
|
||||
export interface ServerNet {
|
||||
on<T = unknown>(channel: Id, handler: (player: Player, data: T) => void): void;
|
||||
send(player: Player, channel: Id, data: unknown): void;
|
||||
broadcast(channel: Id, data: unknown): void;
|
||||
}
|
||||
@@ -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<T> {
|
||||
subscribe(handler: (event: T) => void): Unsubscribe;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ItemJson {
|
||||
id: string;
|
||||
texture: string;
|
||||
tool?: string;
|
||||
places?: string;
|
||||
max_stack?: number;
|
||||
lore?: string;
|
||||
components?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RecipeJson =
|
||||
| {
|
||||
type: "shaped";
|
||||
pattern: string[];
|
||||
key: Record<string, string>;
|
||||
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<string, string> = {};
|
||||
const letters = new Map<string, string>();
|
||||
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<RecipeJson, { type: "shaped" }>): 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<string>) {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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`. |
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:dirt",
|
||||
"textures": "bworld:dirt",
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 2,
|
||||
"tool": "shovel"
|
||||
},
|
||||
"drops": "bworld:dirt"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:glass",
|
||||
"textures": "bworld:glass",
|
||||
"transparent": true,
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "pickaxe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:leaves",
|
||||
"textures": "bworld:leaves",
|
||||
"transparent": true,
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "hoe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:planks",
|
||||
"textures": "bworld:planks",
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "axe"
|
||||
},
|
||||
"drops": "bworld:log"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:sand",
|
||||
"textures": "bworld:sand",
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "shovel"
|
||||
},
|
||||
"drops": "bworld:sand"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:snow",
|
||||
"textures": "bworld:snow",
|
||||
"mining": {
|
||||
"toughness": 2,
|
||||
"tool": "shovel",
|
||||
"requires_tool": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:axe",
|
||||
"texture": "bworld:axe",
|
||||
"tool": "axe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:coal",
|
||||
"texture": "bworld:coal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:copper_ingot",
|
||||
"texture": "bworld:copper_ingot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:gold_ingot",
|
||||
"texture": "bworld:gold_ingot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:hoe",
|
||||
"texture": "bworld:hoe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:iron_ingot",
|
||||
"texture": "bworld:iron_ingot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:pickaxe",
|
||||
"texture": "bworld:pickaxe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:stick",
|
||||
"texture": "bworld:stick"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:tin_ingot",
|
||||
"texture": "bworld:tin_ingot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:watering_can",
|
||||
"texture": "bworld:watering_can"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:wood_pickaxe",
|
||||
"texture": "bworld:wood_pickaxe",
|
||||
"tool": "pickaxe"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "shaped",
|
||||
"pattern": [
|
||||
"L"
|
||||
],
|
||||
"key": {
|
||||
"L": "bworld:log"
|
||||
},
|
||||
"result": {
|
||||
"id": "bworld:planks",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "shaped",
|
||||
"pattern": [
|
||||
"P",
|
||||
"P"
|
||||
],
|
||||
"key": {
|
||||
"P": "bworld:planks"
|
||||
},
|
||||
"result": {
|
||||
"id": "bworld:stick",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "fuel",
|
||||
"item": "bworld:coal",
|
||||
"burn_time": 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "fuel",
|
||||
"item": "bworld:log",
|
||||
"burn_time": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:coal_ore",
|
||||
"output": {
|
||||
"id": "bworld:coal",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:copper_ore",
|
||||
"output": {
|
||||
"id": "bworld:copper_ingot",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:gold_ore",
|
||||
"output": {
|
||||
"id": "bworld:gold_ingot",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:iron_ore",
|
||||
"output": {
|
||||
"id": "bworld:iron_ingot",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:log",
|
||||
"output": {
|
||||
"id": "bworld:coal",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "furnace",
|
||||
"input": "bworld:tin_ore",
|
||||
"output": {
|
||||
"id": "bworld:tin_ingot",
|
||||
"count": 1
|
||||
},
|
||||
"cook_time": 200
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
export const FUEL_VALUES: Record<string, number> = {
|
||||
"bworld:coal": 1000,
|
||||
"bworld:log": 100,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Example Mod
|
||||
|
||||
Made by you. List where textures and other assets come from and their licenses here.
|
||||
@@ -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:<file name>`. |
|
||||
| `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.
|
||||
@@ -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!" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
// },
|
||||
// });
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 201 B |
Binary file not shown.
|
After Width: | Height: | Size: 189 B |
@@ -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<string, unknown>();
|
||||
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<ItemRegistry>("items"));
|
||||
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("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<ItemRegistry>("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<T extends object>(value: T): T {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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:<file>
|
||||
const BASE_TEXTURE_DIR = "assets/sprites/textures";
|
||||
|
||||
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
if (id === "engine") {
|
||||
error(`"engine" is reserved for the engine itself`);
|
||||
}
|
||||
|
||||
const scripts = (mod.manifest?.scripts ?? {}) as Record<string, string>;
|
||||
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 = <T>(
|
||||
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<string, string[]>();
|
||||
const item_owners = new Map<string, string[]>();
|
||||
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
|
||||
|
||||
const textures = new Set<string>();
|
||||
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<string, string>)
|
||||
.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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
@@ -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<ItemRegistry>("items"));
|
||||
|
||||
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("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}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// deno task new-mod <id> ["Display Name"]
|
||||
// copies templates/mod into mods/<id>, 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 <id> ["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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user