265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
// 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<ReturnType<typeof test_game>>;
|
|
|
|
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<BlockRegistry>("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);
|
|
});
|