Implement main game as a mod

This commit is contained in:
2026-09-24 23:49:34 -03:00
parent bb42dd662e
commit 6458bc0440
131 changed files with 1810 additions and 786 deletions
-65
View File
@@ -1,65 +0,0 @@
import { assertEquals } from "@std/assert";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import {
block_from_json,
block_to_json,
grid_recipe_from_json,
grid_recipe_to_json,
item_from_json,
item_to_json,
} from "$/common/mod_data.ts";
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
import { bworld_data_files } from "$/tools/export_bworld_mod.ts";
// mods/bworld is generated from the typescript definitions until the loader exists, keep them in sync
Deno.test("mods/bworld matches what the game registers", () => {
const expected = new Map(bworld_data_files().map(({ path, content }) => [path, content]));
const actual = new Map<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;
}
+65
View File
@@ -0,0 +1,65 @@
import { resolve, toFileUrl } from "@std/path";
import { EverythingRegistry } from "$/common/everything_registry.ts";
import { load_and_check, load_order } from "$/tools/check_mods.ts";
import type { ServerModSource } from "$/server/game/load_mods.ts";
import { GameServer } from "$/server/game/game_server.ts";
import { start_game } from "$/server/game/load_mods.ts";
// mods straight from their source folders, skipping the build: scripts are imported as typescript
export function mod_sources(mods_dir: string): ServerModSource[] {
const mods = load_and_check(mods_dir);
const errors = mods.flatMap((mod) => mod.report.errors.map((e) => `${mod.id}: ${e}`));
if (errors.length) throw new Error(errors.join("\n"));
return load_order(mods).map((mod) => {
const scripts = (mod.manifest?.scripts ?? {}) as Record<string, string>;
const url = (path?: string) => path ? toFileUrl(resolve(mod.dir, path)).href : undefined;
return {
listing: {
id: mod.id,
name: String(mod.manifest?.name),
version: String(mod.manifest?.version),
hash: "source",
data: `mods/${mod.id}/source/data.json`,
},
data: {
blocks: mod.blocks.map((b) => b.json),
items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json),
},
server_url: url(scripts.server),
worldgen_url: url(scripts.worldgen),
};
});
}
// a game with an in memory host that remembers what it sent to each connection
export async function test_game(mods_dir: string, save?: string, seed = "test-seed") {
EverythingRegistry.clear();
const outbox = new Map<number, unknown[]>();
const host = {
send: (conn: number, data: string) => {
if (!outbox.has(conn)) outbox.set(conn, []);
outbox.get(conn)!.push(JSON.parse(data));
},
close() {},
};
const game: GameServer = await start_game(host, save, seed, mod_sources(mods_dir));
// deno-lint-ignore no-explicit-any
const take = (conn: number): any[] => {
const messages = outbox.get(conn) ?? [];
outbox.set(conn, []);
return messages;
};
const send = (conn: number, message: unknown) => game.on_message(conn, JSON.stringify(message));
return { game, take, send };
}
export function copy_dir(from: string, to: string) {
if (Deno.statSync(from).isDirectory) {
Deno.mkdirSync(to, { recursive: true });
for (const entry of Deno.readDirSync(from)) copy_dir(`${from}/${entry.name}`, `${to}/${entry.name}`);
} else {
Deno.copyFileSync(from, to);
}
}
+213
View File
@@ -0,0 +1,213 @@
import { assert, assertEquals } from "@std/assert";
import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts";
import { generate_raw_chunk } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { create_mod } from "$/tools/new_mod.ts";
import { copy_dir, test_game } from "./helpers.ts";
// mods/bworld plus a mod made from the template, in a temp folder
function mods_with_template() {
const dir = Deno.makeTempDirSync({ prefix: "bworld_loading_" });
copy_dir("mods/bworld", `${dir}/bworld`);
create_mod("copper_tools", "Copper Tools", dir);
return dir;
}
// deno-lint-ignore no-explicit-any
type Msg = any;
const inventory_of = (messages: Msg[]) =>
messages.filter((m) => m.type === "container" && m.container === "inventory").at(-1)?.items ?? [];
Deno.test("the base game loads from mods/bworld", async () => {
const { game } = await test_game("mods");
assertEquals(EverythingRegistry.entries("blocks").length, 18);
// 11 items plus the item forms of the 16 blocks that have one
assertEquals(EverythingRegistry.entries("items").length, 27);
assertEquals(game.recipes.shaped.length, 5);
assertEquals(game.recipes.furnace.size, 6);
assertEquals(game.recipes.fuel.size, 2);
assertEquals(EverythingRegistry.get<BlockRegistry>("blocks", "bworld:water")?.replaceable, true);
// still has its code until it's a component
assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create);
});
Deno.test("mods/bworld json survives going to the registry and back", async () => {
await test_game("mods");
for (const file of Deno.readDirSync("mods/bworld/blocks")) {
const { block: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/blocks/${file.name}`));
const { block, has_item } = block_from_json(json);
assertEquals(block_to_json(block, has_item), json, file.name);
}
for (const file of Deno.readDirSync("mods/bworld/items")) {
const { item: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/items/${file.name}`));
assertEquals(item_to_json(json.id, item_from_json(json)), json, file.name);
}
});
Deno.test("a mod made from the template loads and runs", async () => {
const dir = mods_with_template();
const { game, take, send } = await test_game(dir);
// data
assert(EverythingRegistry.get("blocks", "copper_tools:example_block"));
assert(EverythingRegistry.get("items", "copper_tools:example_item"));
assertEquals(
EverythingRegistry.get<ItemRegistry>("items", "copper_tools:example_item")?.lore,
"Four of these make an example block.",
);
game.on_connect(1);
send(1, { type: "hello", name: "alice" });
const welcome = take(1)[0];
assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]);
// the command, by name and by namespaced name
send(1, { type: "chat", text: "/hello" });
send(1, { type: "chat", text: "/copper_tools:hello" });
const hellos = take(1).filter((m) => m.type === "chat" && m.text === "Hello alice!");
assertEquals(hellos.length, 2);
// place the example block and right click it: the component answers and nothing gets placed on top
send(1, { type: "chat", text: "/give copper_tools:example_block" });
const inventory = inventory_of(take(1));
send(1, { type: "select_slot", slot: inventory.findIndex((i: Msg) => i?.id === "copper_tools:example_block") });
let y = CHUNK_HEIGHT - 1;
while (game.world.get_block_nid(1, y, 1) === AIR) y--;
send(1, { type: "move", x: 2.5, y: y + 1, z: 2.5, yaw: 0, pitch: 0 });
send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assertEquals(game.world.get_block_id(1, y + 1, 1), "copper_tools:example_block");
take(1);
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
const messages = take(1);
assert(messages.some((m) => m.type === "chat" && m.text === "You found the example block!"));
assertEquals(game.world.get_block_id(1, y + 2, 1), "bworld:air");
// the shaped recipe: 4 example items in a square
send(1, { type: "chat", text: "/give copper_tools:example_item 4" });
const items_slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "copper_tools:example_item");
send(1, { type: "click", container: "inventory", index: items_slot, button: 0 });
for (const slot of [0, 1, 3, 4]) send(1, { type: "click", container: "crafting", index: slot, button: 2 });
const crafting = take(1).filter((m) => m.container === "crafting").at(-1).items;
assertEquals(crafting[9]?.id, "copper_tools:example_block");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("the template's worldgen feature places boulders, the same way every time", async () => {
const dir = mods_with_template();
const { game } = await test_game(dir, undefined, "boulder-seed");
const worldgen = game.world.worldgen!;
assertEquals(worldgen.features.map((f) => f.id), ["copper_tools:boulders"]);
// compare against generation without mods: boulders are extra stone on top of the surface
let boulders = 0;
for (let cx = 0; cx < 20; cx++) {
const plain = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids);
const modded = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen);
const differences = plain.blocks.filter((b, i) => b !== modded.blocks[i]).length;
if (differences > 0) boulders++;
assert(differences <= 1, `chunk ${cx} changed ${differences} blocks`);
}
assert(boulders >= 1 && boulders <= 8, `${boulders} of 20 chunks got a boulder, expected about 2`);
// loading the script again (like a client does) gives the same terrain
const again = await load_worldgen(
[{
mod: "copper_tools",
url: new URL(`file://${Deno.realPathSync(dir)}/copper_tools/scripts/worldgen.ts`).href,
}],
[],
);
for (let cx = 0; cx < 20; cx++) {
assertEquals(
generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, again).blocks,
generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen).blocks,
);
}
Deno.removeSync(dir, { recursive: true });
});
Deno.test("mod ores generate into the block they replace", async () => {
const dir = mods_with_template();
Deno.mkdirSync(`${dir}/copper_tools/worldgen`);
Deno.writeTextFileSync(
`${dir}/copper_tools/worldgen/ores.json`,
JSON.stringify({
format_version: 1,
ores: [{
id: "copper_tools:example_block",
replaces: "bworld:stone",
min_y: 0,
max_y: 60,
scale: 0.1,
threshold: 0.5,
}],
}),
);
const { game } = await test_game(dir);
const ore = game.world.block_ids["copper_tools:example_block"];
let found = 0;
for (let cx = 0; cx < 4; cx++) {
const blocks = generate_raw_chunk(cx, 0, "test-seed", game.world.block_ids, game.world.worldgen).blocks;
blocks.forEach((b, i) => {
if (b === ore) {
found++;
assert(Math.floor(i / 256) <= 60, "ore above max_y");
}
});
}
assert(found > 0, "no ore generated");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("mod storage and block data are saved with the world", async () => {
const dir = mods_with_template();
// a server script that uses storage and block data
Deno.writeTextFileSync(
`${dir}/copper_tools/scripts/server.ts`,
`import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
ctx.components.register_block("copper_tools:announce", {
on_create(block) { block.data = { placed_at: ctx.system.current_tick }; },
on_interact(block, _params, player) {
ctx.storage.set("clicks", (ctx.storage.get<number>("clicks") ?? 0) + 1);
player.send_message(\`clicks \${ctx.storage.get("clicks")}, placed at \${block.data.placed_at}\`);
return true;
},
});
}
`,
);
let { game, send, take } = await test_game(dir);
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
for (let i = 0; i < 5; i++) game.tick();
game.set_block(0, 100, 0, "copper_tools:example_block");
send(1, { type: "move", x: 0.5, y: 100, z: 1.5, yaw: 0, pitch: 0 });
send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" });
assert(take(1).some((m) => m.text === "clicks 1, placed at 5"));
const saved = game.save();
({ game, send, take } = await test_game(dir, saved));
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" });
assert(take(1).some((m) => m.text === "clicks 2, placed at 5"));
Deno.removeSync(dir, { recursive: true });
});
Deno.test("broken mods stop the game from starting", async () => {
const dir = mods_with_template();
// the block uses a component the script no longer registers
Deno.writeTextFileSync(`${dir}/copper_tools/scripts/server.ts`, "export function setup() {}\n");
let error = "";
try {
await test_game(dir);
} catch (e) {
error = (e as Error).message;
}
assert(error.includes("uses component copper_tools:announce, which nothing registered"), error);
Deno.removeSync(dir, { recursive: true });
});