Implement main game as a mod
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user