Implement main game as a mod
This commit is contained in:
@@ -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