218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
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 { PROTOCOL_VERSION } from "$/common/protocol.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, 19);
|
|
// 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", protocol: PROTOCOL_VERSION });
|
|
const welcome = take(1)[0];
|
|
assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]);
|
|
send(1, { type: "ready" });
|
|
|
|
// 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.startsWith("You found the example block!")));
|
|
// it counts its age with on_second
|
|
for (let i = 0; i < 3 * 20; i++) game.tick();
|
|
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
|
|
assert(take(1).some((m) => m.text === "You found the example block! It's been here for 3 seconds."));
|
|
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, join } = await test_game(dir);
|
|
join(1, "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, join } = await test_game(dir, saved));
|
|
join(1, "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 });
|
|
});
|