// the base game's block and item behavior: hoeing, chests, furnaces, crops and the watering can import { assert, assertEquals } from "@std/assert"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { get_state_value } from "$/common/utils.ts"; import { test_game } from "./helpers.ts"; // deno-lint-ignore no-explicit-any type Msg = any; type Game = Awaited>; const last_container = (messages: Msg[], container: string) => messages.filter((m) => m.type === "container" && m.container === container).at(-1)?.items; // a player standing on a stone floor at y 99, high above the terrain, with the items given in their inventory async function setup(items: [string, number][] = []) { const t = await test_game("mods"); t.join(1, "alice"); for (let x = -3; x <= 3; x++) { for (let z = -3; z <= 3; z++) t.game.set_block(x, 99, z, "bworld:stone"); } t.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); for (const [id, count] of items) t.send(1, { type: "chat", text: `/give ${id} ${count}` }); return t; } // the inventory slot holding an item, from what the server sent last function slot_of(messages: Msg[], id: string) { const slot = (last_container(messages, "inventory") ?? []).findIndex((i: Msg) => i?.id === id); assert(slot >= 0, `no ${id} in the inventory`); return slot; } // picks a whole inventory stack up and puts it into a slot of the open screen function move_to_screen(t: Game, from: number, to: number) { t.send(1, { type: "click", container: "inventory", index: from, button: 0 }); t.send(1, { type: "click", container: "screen", index: to, button: 0 }); } Deno.test("a hoe turns grass and dirt into hoed dirt, anything else places nothing", async () => { const t = await setup([["bworld:hoe", 1]]); t.game.set_block(1, 100, 0, "bworld:grass"); t.game.set_block(2, 100, 0, "bworld:dirt"); t.send(1, { type: "select_slot", slot: slot_of(t.take(1), "bworld:hoe") }); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); t.send(1, { type: "use_block", x: 2, y: 100, z: 0, face: "top" }); assertEquals(t.game.world.get_block_id(1, 100, 0), "bworld:hoed_dirt"); assertEquals(t.game.world.get_block_id(2, 100, 0), "bworld:hoed_dirt"); // hoed dirt stays hoed dirt, and the hand doesn't do it t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); assertEquals(t.game.world.get_block_id(1, 100, 0), "bworld:hoed_dirt"); t.game.set_block(1, 100, 1, "bworld:grass"); t.send(1, { type: "select_slot", slot: 8 }); t.send(1, { type: "use_block", x: 1, y: 100, z: 1, face: "top" }); assertEquals(t.game.world.get_block_id(1, 100, 1), "bworld:grass"); }); Deno.test("a chest keeps items, shows them to everyone, and drops them when broken", async () => { const t = await setup([["bworld:chest", 1], ["bworld:coal", 10]]); t.game.set_block(1, 100, 0, "bworld:chest"); const coal = slot_of(t.take(1), "bworld:coal"); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); const opened = t.take(1).find((m) => m.type === "open_screen"); assertEquals(opened.layout.rows, 3); assertEquals(opened.layout.slots.length, 27); // nothing got placed on top assertEquals(t.game.world.get_block_id(1, 101, 0), "bworld:air"); move_to_screen(t, coal, 4); assertEquals(last_container(t.take(1), "screen")[4], { id: "bworld:coal", count: 10 }); t.send(1, { type: "close_screen" }); // still there after opening it again, and after saving and loading const loaded = await test_game("mods", t.game.save()); loaded.join(1, "alice"); loaded.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); loaded.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); assertEquals(last_container(loaded.take(1), "screen")[4], { id: "bworld:coal", count: 10 }); // breaking it closes the screen and drops the chest and what was inside loaded.send(1, { type: "break_block", x: 1, y: 100, z: 0 }); const messages = loaded.take(1); assert(messages.some((m) => m.type === "close_screen")); const dropped = loaded.game.item_entities().map((e) => e.item.to_data()); assertEquals( dropped.sort((a, b) => a.id.localeCompare(b.id)), [{ id: "bworld:chest", count: 1 }, { id: "bworld:coal", count: 10 }], ); }); Deno.test("a furnace burns fuel to smelt, shows its progress, and only lets the result be taken", async () => { const t = await setup([["bworld:iron_ore", 2], ["bworld:coal", 1]]); t.game.set_block(1, 100, 0, "bworld:furnace"); const messages = t.take(1); const ore = slot_of(messages, "bworld:iron_ore"); const coal = slot_of(messages, "bworld:coal"); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); const opened = t.take(1).find((m) => m.type === "open_screen"); assertEquals(opened.layout.slots.map((s: Msg) => s.index), [0, 1, 2]); assertEquals(opened.layout.slots[2].output, true); assertEquals(opened.layout.bars.map((b: Msg) => b.value), ["fuel", "progress"]); move_to_screen(t, ore, 0); move_to_screen(t, coal, 1); t.take(1); // iron takes 200 ticks for (let i = 0; i < 100; i++) t.game.tick(); const halfway = t.take(1).filter((m) => m.type === "screen_properties").at(-1).properties; assertEquals(halfway.progress_max, 200); assert(halfway.progress >= 98 && halfway.progress <= 100, `progress ${halfway.progress}`); assertEquals(halfway.fuel_max, 1000); for (let i = 0; i < 110; i++) t.game.tick(); const screen = last_container(t.take(1), "screen"); assertEquals(screen[0], { id: "bworld:iron_ore", count: 1 }); assertEquals(screen[1], null, "the coal got used"); assertEquals(screen[2], { id: "bworld:iron_ingot", count: 1 }); // nothing can be put into the result slot, but the result can be taken t.send(1, { type: "click", container: "screen", index: 0, button: 0 }); t.send(1, { type: "click", container: "screen", index: 2, button: 0 }); assertEquals(last_container(t.take(1), "screen")[2], { id: "bworld:iron_ingot", count: 1 }); t.send(1, { type: "click", container: "screen", index: 0, button: 0 }); t.send(1, { type: "click", container: "screen", index: 2, button: 0 }); const after = t.take(1); assertEquals(last_container(after, "screen")[2], null); assertEquals(after.filter((m) => m.type === "cursor").at(-1).item, { id: "bworld:iron_ingot", count: 1 }); }); Deno.test("bone meal grows wheat until it's fully grown", async () => { const t = await setup([["bworld:bone_meal", 8]]); t.game.set_block(1, 100, 0, "bworld:wheat"); const wheat = EverythingRegistry.get("blocks", "bworld:wheat")!; const age = () => get_state_value(t.game.world.get_block_value(1, 100, 0), wheat, "age")!; assertEquals(age(), 0); const slot = slot_of(t.take(1), "bworld:bone_meal"); t.send(1, { type: "select_slot", slot }); // one stage at a time, and once it's fully grown it stops using bone meal t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); assertEquals(age(), 1); for (let i = 0; i < 7; i++) t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); assertEquals(age(), 7); assertEquals(last_container(t.take(1), "inventory")[slot]?.count, 1); assertEquals(t.game.world.get_block_id(1, 101, 0), "bworld:air"); }); Deno.test("the last bone meal is used up", async () => { const t = await setup([["bworld:bone_meal", 1]]); t.game.set_block(1, 100, 0, "bworld:wheat"); const slot = slot_of(t.take(1), "bworld:bone_meal"); t.send(1, { type: "select_slot", slot }); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); assertEquals(last_container(t.take(1), "inventory")[slot], null); }); Deno.test("a new watering can starts empty", async () => { const t = await setup([["bworld:watering_can", 1]]); const can = last_container(t.take(1), "inventory").find((i: Msg) => i?.id === "bworld:watering_can"); assertEquals(can.data, { water: 0, max_water: 32 }); }); Deno.test("chests and furnaces from a world saved before they were mod code keep their items and keep smelting", async () => { const t = await test_game("mods", Deno.readTextFileSync("tests/fixtures/world_v2.json"), "fixture-seed"); t.join(1, "alice"); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); const chest = last_container(t.take(1), "screen"); assertEquals(chest.length, 27); assertEquals(chest[4], { id: "bworld:coal", count: 6 }); t.send(1, { type: "close_screen" }); t.send(1, { type: "use_block", x: 2, y: 100, z: 0, face: "top" }); let messages = t.take(1); assertEquals(last_container(messages, "screen"), [ { id: "bworld:iron_ore", count: 2 }, { id: "bworld:coal", count: 2 }, { id: "bworld:iron_ingot", count: 1 }, ]); const properties = messages.filter((m) => m.type === "screen_properties").at(-1).properties; assertEquals(properties, { progress: 50, progress_max: 200, fuel: 751, fuel_max: 1000 }); // 150 more ticks finish the second ingot for (let i = 0; i < 150; i++) t.game.tick(); messages = t.take(1); assertEquals(last_container(messages, "screen")[2], { id: "bworld:iron_ingot", count: 2 }); // saved in the new format, nothing lost const saved = JSON.parse(t.game.save()); assertEquals(saved.version, 3); assert(saved.tiles.every((tile: Msg) => tile.data === undefined && tile.containers === undefined)); assertEquals(Object.keys(saved.containers).length, 2); }); Deno.test("a smithing table upgrades a wooden pickaxe with 5 stone, using up exactly that", async () => { const t = await setup([["bworld:wood_pickaxe", 1], ["bworld:stone", 7]]); t.game.set_block(1, 100, 0, "bworld:smithing_table"); let messages = t.take(1); const pickaxe = slot_of(messages, "bworld:wood_pickaxe"); const stone = slot_of(messages, "bworld:stone"); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); const opened = t.take(1).find((m) => m.type === "open_screen"); assertEquals(opened.layout.slots.map((s: Msg) => [s.index, s.output ?? false]), [ [0, false], [1, false], [2, false], [3, true], ]); // with only the tool there's nothing to take move_to_screen(t, pickaxe, 0); t.game.tick(); t.send(1, { type: "click", container: "screen", index: 3, button: 0 }); assertEquals(t.take(1).filter((m) => m.type === "cursor").at(-1)?.item ?? null, null); // the result shows up once there's enough stone, and taking it uses up the pickaxe and 5 stone move_to_screen(t, stone, 1); t.game.tick(); assertEquals(last_container(t.take(1), "screen")[3], { id: "bworld:stone_pickaxe", count: 1 }); t.send(1, { type: "click", container: "screen", index: 3, button: 0 }); messages = t.take(1); assertEquals(messages.filter((m) => m.type === "cursor").at(-1).item, { id: "bworld:stone_pickaxe", count: 1 }); assertEquals(last_container(messages, "screen"), [null, { id: "bworld:stone", count: 2 }, null, null]); // closing gives back the leftover stone, and the cursor's pickaxe goes into the inventory t.send(1, { type: "close_screen" }); const inventory = last_container(t.take(1), "inventory").filter((i: Msg) => i); assertEquals( inventory.sort((a: Msg, b: Msg) => a.id.localeCompare(b.id)), [{ id: "bworld:stone", count: 2 }, { id: "bworld:stone_pickaxe", count: 1 }], ); assertEquals(t.game.containers.size, 0); }); Deno.test("a smithing result can't be taken once its inputs are gone, even before it updates", async () => { const t = await setup([["bworld:wood_pickaxe", 1], ["bworld:stone", 5]]); t.game.set_block(1, 100, 0, "bworld:smithing_table"); const messages = t.take(1); t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); move_to_screen(t, slot_of(messages, "bworld:wood_pickaxe"), 0); move_to_screen(t, slot_of(messages, "bworld:stone"), 1); t.game.tick(); // take the pickaxe back out and try the result in the same tick t.send(1, { type: "click", container: "screen", index: 0, button: 0 }); t.send(1, { type: "click", container: "inventory", index: 20, button: 0 }); t.send(1, { type: "click", container: "screen", index: 3, button: 0 }); const cursor = t.take(1).filter((m) => m.type === "cursor").at(-1).item; assertEquals(cursor, null); // and an extra item in the third slot doesn't match a recipe without one t.send(1, { type: "click", container: "inventory", index: 20, button: 0 }); t.send(1, { type: "click", container: "screen", index: 0, button: 0 }); t.send(1, { type: "chat", text: "/give bworld:coal 1" }); const coal = slot_of(t.take(1), "bworld:coal"); move_to_screen(t, coal, 2); t.game.tick(); assertEquals(last_container(t.take(1), "screen")[3], null); });