diff --git a/MODS.md b/MODS.md index 1a2038d..a4ec82b 100644 --- a/MODS.md +++ b/MODS.md @@ -394,6 +394,7 @@ interface ServerContext { world: ServerWorld; players: PlayerList; containers: ContainerApi; + items: ItemApi; recipes: RecipeApi; ui: ServerUi; // see GUIs net: ServerNet; // see Mod channels @@ -477,6 +478,7 @@ interface ServerWorld { get_state(x: number, y: number, z: number, name: string): number | undefined; set_state(x: number, y: number, z: number, name: string, value: number): boolean; get_block_data(x: number, y: number, z: number): T | undefined; + drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops is_loaded(x: number, z: number): boolean; readonly seed: string; } @@ -612,8 +614,8 @@ code sees it. ### 2. Container screens (server only) For inventories and machines. The server owns the container's contents and syncs them to every player viewing it. Clicks -go to the server, which applies them. The chest and furnace would be rebuilt this way instead of their hand-written -`GuiChest` / `GuiFurnace` classes. +go to the server, which applies them. The base game's chest and furnace are built this way, see +`mods/bworld/scripts/server.ts`. ```ts ctx.components.register_block("copper_tools:smelter", { @@ -623,36 +625,50 @@ ctx.components.register_block("copper_tools:smelter", { on_interact(block, _params, player) { const container = ctx.containers.get(block.data.container)!; const screen = ctx.ui.open_container(player, { - title: "Smelter", + container, layout: [ { slot: 0, x: 3, y: 0, filter: "smeltable" }, { slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) }, { slot: 2, x: 5, y: 1, output_only: true }, ], - container, - player_inventory: true, - bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], + bars: [{ + x: 4, + y: 1, + value: "progress", + max: "progress_max", + direction: "right", + empty_texture: "bworld:arrow_empty", + full_texture: "bworld:arrow_full", + }], }); screen.set_property("progress", block.data.progress); + screen.set_property("progress_max", 200); return true; }, + on_break(block) { + ctx.containers.delete(block.data.container); + }, }); ``` -- `x` / `y` are in slot units on a grid. The client scales and centers the screen, and draws the player's inventory - below it when `player_inventory` is true. +- The screen is drawn below the player's inventory and hotbar. `x` / `y` are in slot units in the screen's own area and + can be fractional. `rows` sets its height, by default it fits the slots and bars. - `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and `"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client. -- `bars` are progress bars filled from 0 to 1 by `screen.set_property(id, value)`. Properties can also be shown as text - with `labels: [{ x, y, property }]`. -- The screen handle has `set_property`, `close()` and `on_close(fn)`. Changes to the container (from scripts, hoppers, + `output_only` slots can only be taken from, all at once, like the furnace result. +- `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`. + `direction` is `"up"` (like the furnace's fire) or `"right"` (like its arrow). Properties are synced when they change, + so a component can update them every tick. +- The screen handle has `player`, `open`, `set_property`, `close()` and `on_close(fn)`. It closes when the player closes + it, leaves, or opens another screen, and when its container is deleted. Changes to the container (from scripts, other players) show up for everyone viewing it. +- Containers are saved with the world until they're deleted, so a block that has one deletes it in `on_break`. ```ts interface ContainerApi { - create(size: number): Container; // saved with the world + create(size: number): Container; // 1 to 256 slots, saved with the world get(id: string): Container | undefined; - delete(id: string): void; + delete(id: string): void; // closes screens showing it } interface Container { @@ -663,8 +679,15 @@ interface Container { add(item: ItemStack): ItemStack | undefined; // returns what didn't fit on_change(fn: (slot: number) => void): () => void; } + +interface ItemApi { + exists(id: string): boolean; + max_stack(id: string): number; +} ``` +Items handed to scripts are live: setting `count` changes the stack, and a stack counted down to 0 is gone. + ### 3. Custom screens (client code) For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a @@ -1036,15 +1059,21 @@ export function setup(ctx: ServerContext) { on_interact(block, _params, player) { const data = block.data as SmelterData; ctx.ui.open_container(player, { - title: "Smelter", container: ctx.containers.get(data.container)!, layout: [ { slot: 0, x: 3, y: 0, filter: "smeltable" }, { slot: 1, x: 3, y: 2, filter: "fuel" }, { slot: 2, x: 5, y: 1, output_only: true }, ], - player_inventory: true, - bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], + bars: [{ + x: 4, + y: 1, + value: "progress", + max: "progress_max", + direction: "right", + empty_texture: "bworld:arrow_empty", + full_texture: "bworld:arrow_full", + }], }).set_property("progress", data.progress); return true; }, @@ -1126,10 +1155,10 @@ anything the base game does. Not moved: -- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt - as a component once server scripts exist, as a good first test of tile data plus ticking. -- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves - once tooltips exist. +- **Carrots, potatoes, tomatoes and pumpkins** (`common/blocks/crops.ts`) are unfinished and mostly commented out. + Wheat uses the `bworld:crop` component instead, and they can too once they have blocks. +- **The watering can's lore** moved into its component's `get_lore`, but it isn't shown anywhere yet, because the game + has no item tooltips. ### What stays in the engine @@ -1150,7 +1179,8 @@ Worlds saved before the move must load afterwards with nothing changed: 3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main` plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved by the current code load into them. -4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod. +4. **The protocol doesn't change, and old saves load.** Clients receive `mods/bworld` like any other mod. The save + format may change when it has to, as long as older saves are upgraded when loading (phase 2 did, to version 3). ### Phases @@ -1180,14 +1210,17 @@ loading mods (the base game and the template), their scripts and worldgen, saves - Delete `common/blocks/` and `common/items/`. - Check: the registry built from JSON equals `registry.json`, and all tests pass. -**Phase 2: behavior** (after step 6, and container screens from step 7). +**Phase 2: behavior** (after step 6, and container screens from step 7). _Done._ -- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and - `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the - `ScreenLayout`s the server sends now. -- The block JSON lists the components. Delete `server/game/blocks.ts`. -- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and - `world_v2.json` still pass. +- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, `bworld:crop` (bone + meal on wheat) and `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, with + the same layouts as before. +- The block and item JSON lists the components. `server/game/blocks.ts` is deleted. +- Saves went to version 3: tiles only keep `mod_data`, and containers are saved by id. Version 2 chests and furnaces are + upgraded when loaded: their `data` fields stay and their `containers.main` becomes a container whose id is in + `container`, which is the shape the components use. +- Check: `tests/base_game_test.ts` (hoeing, chest, furnace smelting, breaking a chest gives its contents back, bone meal, + the watering can) and `tests/fixtures/world_v2.json`, saved by the engine code before the move, pass. **Phase 3: world generation** (after step 9). @@ -1239,10 +1272,11 @@ working: joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Done._ 6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and `FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in - `server/game/game_loop.ts`). `ctx.containers`, `ctx.ui` and `ctx.net` throw until steps 7 and 8, and so do the - client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`. + `server/game/game_loop.ts`). `ctx.net` throws until step 8, and so do the client's `ctx.ui`, `ctx.hud`, + `ctx.input` and `ctx.net`. 7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics` - API (built on the existing renderer and debug UI widgets) and the HUD. + API (built on the existing renderer and debug UI widgets) and the HUD. _Container screens done_ (`ctx.containers`, + `ctx.ui.open_container`), forms and custom screens throw until they're built. 8. **Mod channels** and keybinds. 9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing before generating anything. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base diff --git a/common/inventory.ts b/common/inventory.ts index bb66e39..347fb25 100644 --- a/common/inventory.ts +++ b/common/inventory.ts @@ -49,7 +49,7 @@ export class ContainerSlot { #item_stack: ItemStack | undefined; has_item() { - return this.#item_stack !== undefined; + return this.get_item() !== undefined; } set_item(item_stack: ItemStack | undefined) { @@ -60,11 +60,15 @@ export class ContainerSlot { } get_item() { + // scripts can count a stack down to nothing, it's gone then + if (this.#item_stack && this.#item_stack.amount <= 0) { + this.#item_stack = undefined; + } return this.#item_stack; } get type_id() { - return this.#item_stack?.type_id; + return this.get_item()?.type_id; } set amount(new_amount: number) { @@ -77,11 +81,11 @@ export class ContainerSlot { } get amount(): number | undefined { - return this.#item_stack?.amount; + return this.get_item()?.amount; } get max_amount() { - return this.#item_stack?.max_amount; + return this.get_item()?.max_amount; } } diff --git a/common/mod_api/server.ts b/common/mod_api/server.ts index 88af2f6..870beb4 100644 --- a/common/mod_api/server.ts +++ b/common/mod_api/server.ts @@ -12,6 +12,7 @@ export interface ServerContext { world: ServerWorld; players: PlayerList; containers: ContainerApi; + items: ItemApi; recipes: RecipeApi; ui: ServerUi; net: ServerNet; @@ -123,6 +124,8 @@ export interface ServerWorld { get_state(x: number, y: number, z: number, name: string): number | undefined; set_state(x: number, y: number, z: number, name: string, value: number): boolean; get_block_data(x: number, y: number, z: number): T | undefined; + // pops an item out of the block at x y z, like a broken block's drops + drop_item(x: number, y: number, z: number, item: ItemStack): void; is_loaded(x: number, z: number): boolean; readonly seed: string; } @@ -177,6 +180,12 @@ export interface ContainerApi { delete(id: string): void; } +export interface ItemApi { + exists(id: Id): boolean; + // how many fit in one slot + max_stack(id: Id): number; +} + export interface RecipeApi { furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined; fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel @@ -214,20 +223,39 @@ export type FormResult = ({ canceled: true } & Partial) | ({ canceled: fal export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean); export interface ContainerScreenOptions { - title: string; container: Container; - // x and y in slot units - layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[]; - player_inventory?: boolean; - bars?: { id: string; x: number; y: number; texture: Id }[]; - labels?: { x: number; y: number; property: string }[]; + // x and y in slot units, below the player's inventory. can be fractional + layout: { + slot: number; + x: number; + y: number; + // what can be put in, checked on the server + filter?: SlotFilter; + // can only be taken from, all at once, like the furnace result + output_only?: boolean; + }[]; + // height of the screen's area in slots, fits the layout and bars when not set + rows?: number; + // progress bars, filled with the property value divided by the property max + bars?: { + x: number; + y: number; + value: string; + max: string; + direction: "up" | "right"; + empty_texture: Id; + full_texture: Id; + }[]; } export interface ScreenHandle { readonly player: Player; + readonly open: boolean; + // numbers the bars show, synced to the player when they change set_property(id: string, value: number): void; update(props: Props): void; // custom screens only close(): void; + // when it closes for any reason: the player closed it, left, opened another screen, or it was closed on_close(fn: () => void): void; } diff --git a/mods/bworld/blocks/chest.json b/mods/bworld/blocks/chest.json index b1cd08b..2fefe62 100644 --- a/mods/bworld/blocks/chest.json +++ b/mods/bworld/blocks/chest.json @@ -8,6 +8,11 @@ "tool": "axe" }, "drops": "bworld:chest", - "interactive": true + "interactive": true, + "components": { + "bworld:storage": { + "rows": 3 + } + } } } diff --git a/mods/bworld/blocks/dirt.json b/mods/bworld/blocks/dirt.json index 3945190..af17b2c 100644 --- a/mods/bworld/blocks/dirt.json +++ b/mods/bworld/blocks/dirt.json @@ -7,6 +7,12 @@ "toughness": 2, "tool": "shovel" }, - "drops": "bworld:dirt" + "drops": "bworld:dirt", + "components": { + "bworld:hoeable": { + "tool": "bworld:hoe", + "into": "bworld:hoed_dirt" + } + } } } diff --git a/mods/bworld/blocks/furnace.json b/mods/bworld/blocks/furnace.json index 19f670c..08b56c7 100644 --- a/mods/bworld/blocks/furnace.json +++ b/mods/bworld/blocks/furnace.json @@ -12,6 +12,9 @@ "requires_tool": true }, "drops": "bworld:furnace", - "interactive": true + "interactive": true, + "components": { + "bworld:furnace": {} + } } } diff --git a/mods/bworld/blocks/grass.json b/mods/bworld/blocks/grass.json index c9d458d..cc272bd 100644 --- a/mods/bworld/blocks/grass.json +++ b/mods/bworld/blocks/grass.json @@ -12,6 +12,12 @@ "tool": "shovel" }, "drops": "bworld:dirt", - "item": false + "item": false, + "components": { + "bworld:hoeable": { + "tool": "bworld:hoe", + "into": "bworld:hoed_dirt" + } + } } } diff --git a/mods/bworld/blocks/wheat.json b/mods/bworld/blocks/wheat.json index efc6524..baa6597 100644 --- a/mods/bworld/blocks/wheat.json +++ b/mods/bworld/blocks/wheat.json @@ -61,6 +61,12 @@ "bits": 3, "default": 0 } - ] + ], + "components": { + "bworld:crop": { + "fertilizer": "bworld:bone_meal", + "max_age": 7 + } + } } } diff --git a/mods/bworld/items/watering_can.json b/mods/bworld/items/watering_can.json index c24815b..a90e526 100644 --- a/mods/bworld/items/watering_can.json +++ b/mods/bworld/items/watering_can.json @@ -2,6 +2,11 @@ "format_version": 1, "item": { "id": "bworld:watering_can", - "texture": "bworld:watering_can" + "texture": "bworld:watering_can", + "components": { + "bworld:watering_can": { + "max_water": 32 + } + } } } diff --git a/mods/bworld/manifest.json b/mods/bworld/manifest.json index 417809e..9a197e0 100644 --- a/mods/bworld/manifest.json +++ b/mods/bworld/manifest.json @@ -4,7 +4,12 @@ "name": "bworld", "description": "The base game: its blocks, items, recipes and world.", "version": "0.1.0", - "authors": ["paula"], + "authors": [ + "paula" + ], "game_version": ">=0.1.0", - "credits": "CREDITS.md" + "credits": "CREDITS.md", + "scripts": { + "server": "scripts/server.ts" + } } diff --git a/mods/bworld/scripts/server.ts b/mods/bworld/scripts/server.ts new file mode 100644 index 0000000..58f56ec --- /dev/null +++ b/mods/bworld/scripts/server.ts @@ -0,0 +1,234 @@ +// the base game's block and item behavior, written against the same api as any other mod +import type { BlockRef, Container, ScreenHandle, ServerContext } from "bworld/server"; + +export function setup(ctx: ServerContext) { + register_hoeable(ctx); + register_storage(ctx); + register_furnace(ctx); + register_crop(ctx); + register_watering_can(ctx); +} + +// right clicking with a tool turns the block into another, like a hoe on grass +function register_hoeable(ctx: ServerContext) { + ctx.components.register_block<{ tool: string; into: string }>("bworld:hoeable", { + on_interact(block, params, player) { + if (player.held_item?.id !== params.tool) { + return false; + } + ctx.world.set_block(block.x, block.y, block.z, params.into); + return true; + }, + }); +} + +// the container a block keeps its items in, made the first time it's needed. blocks placed before tile data +// existed don't have one yet +function block_container(ctx: ServerContext, block: BlockRef, size: number): Container { + const existing = block.data?.container && ctx.containers.get(block.data.container); + if (existing) { + return existing; + } + const container = ctx.containers.create(size); + block.data = { ...block.data, container: container.id }; + return container; +} + +// what was inside falls out, like minecraft's Containers.dropContents +function drop_contents(ctx: ServerContext, block: BlockRef) { + const container = block.data?.container && ctx.containers.get(block.data.container); + if (!container) { + return; + } + for (let slot = 0; slot < container.size; slot++) { + const item = container.get(slot); + if (item) { + ctx.world.drop_item(block.x, block.y, block.z, item); + } + } + ctx.containers.delete(container.id); +} + +// a chest +function register_storage(ctx: ServerContext) { + ctx.components.register_block<{ rows: number }>("bworld:storage", { + on_create(block, params) { + block_container(ctx, block, params.rows * 9); + }, + on_interact(block, params, player) { + const size = params.rows * 9; + ctx.ui.open_container(player, { + container: block_container(ctx, block, size), + layout: Array.from({ length: size }, (_, slot) => ({ slot, x: slot % 9, y: Math.floor(slot / 9) })), + rows: params.rows, + }); + return true; + }, + on_break(block) { + drop_contents(ctx, block); + }, + }); +} + +// furnace + +// slot 0 is what's smelting, 1 the fuel, 2 the result +interface FurnaceData { + container: string; + // ticks + progress: number; + progress_max: number; + fuel: number; + fuel_max: number; +} + +const FURNACE_BARS = [ + { + x: 3.5, + y: 1, + value: "fuel", + max: "fuel_max", + direction: "up" as const, + empty_texture: "bworld:fire_empty", + full_texture: "bworld:fire_full", + }, + { + x: 4.5, + y: 1, + value: "progress", + max: "progress_max", + direction: "right" as const, + empty_texture: "bworld:arrow_empty", + full_texture: "bworld:arrow_full", + }, +]; + +function register_furnace(ctx: ServerContext) { + // who's looking at each furnace, by position, to keep their bars moving + const viewers = new Map>(); + const key = (block: BlockRef) => `${block.x},${block.y},${block.z}`; + + const furnace_data = (block: BlockRef): FurnaceData => { + const container = block_container(ctx, block, 3); + block.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0, ...block.data, container: container.id }; + return block.data; + }; + + const show = (screen: ScreenHandle, data: FurnaceData) => { + screen.set_property("progress", data.progress); + screen.set_property("progress_max", data.progress_max); + screen.set_property("fuel", data.fuel); + screen.set_property("fuel_max", data.fuel_max); + }; + + // there's somewhere for the result to go + const can_smelt = (container: Container, output: { id: string; count: number }) => { + const result = container.get(2); + return !result || (result.id === output.id && result.count < ctx.items.max_stack(result.id)); + }; + + ctx.components.register_block("bworld:furnace", { + on_create(block) { + furnace_data(block); + }, + on_interact(block, _params, player) { + const data = furnace_data(block); + const screen = ctx.ui.open_container(player, { + container: ctx.containers.get(data.container)!, + layout: [ + { slot: 0, x: 3.5, y: 0 }, + { slot: 1, x: 3.5, y: 2, filter: "fuel" }, + { slot: 2, x: 5.5, y: 1, output_only: true }, + ], + rows: 3, + bars: FURNACE_BARS, + }); + show(screen, data); + const screens = viewers.get(key(block)) ?? new Set(); + viewers.set(key(block), screens); + screens.add(screen); + screen.on_close(() => screens.delete(screen)); + return true; + }, + on_break(block) { + drop_contents(ctx, block); + }, + on_tick(block) { + const data = furnace_data(block); + const container = ctx.containers.get(data.container)!; + const input = container.get(0); + const recipe = input && ctx.recipes.furnace_result(input.id); + + if (data.fuel > 0) { + data.fuel -= 1; + } + + if (!recipe || !can_smelt(container, recipe.output)) { + data.progress = 0; + } else { + // refuel + const fuel = container.get(1); + if (data.fuel === 0 && fuel && ctx.recipes.is_fuel(fuel.id)) { + data.fuel = ctx.recipes.fuel_value(fuel.id); + data.fuel_max = data.fuel; + fuel.count -= 1; + } + + // cook + if (data.fuel > 0) { + data.progress_max = recipe.cook_time; + data.progress += 1; + if (data.progress >= recipe.cook_time) { + data.progress = 0; + const result = container.get(2); + if (result) { + result.count += recipe.output.count; + } else { + container.set(2, recipe.output); + } + input.count -= 1; + } + } + } + + for (const screen of viewers.get(key(block)) ?? []) show(screen, data); + }, + }); +} + +// grows one stage each time it's right clicked with the fertilizer, like bone meal on wheat +function register_crop(ctx: ServerContext) { + ctx.components.register_block<{ fertilizer: string; max_age: number }>("bworld:crop", { + on_interact(block, params, player) { + const held = player.held_item; + if (held?.id !== params.fertilizer) { + return false; + } + const age = ctx.world.get_state(block.x, block.y, block.z, "age") ?? 0; + // fully grown, keep the fertilizer + if (age >= params.max_age) { + return false; + } + ctx.world.set_state(block.x, block.y, block.z, "age", age + 1); + held.count -= 1; + return true; + }, + }); +} + +interface WateringCanData { + water: number; + max_water: number; +} + +function register_watering_can(ctx: ServerContext) { + ctx.components.register_item<{ max_water: number }>("bworld:watering_can", { + on_create(item, params) { + item.data = { water: 0, max_water: params.max_water } satisfies WateringCanData; + }, + get_lore(item) { + const data = item.data as WateringCanData; + return `Water: ${data.water}/${data.max_water}`; + }, + }); +} diff --git a/server/game/blocks.ts b/server/game/blocks.ts deleted file mode 100644 index a2b7ca0..0000000 --- a/server/game/blocks.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Container, ItemStack } from "$/common/inventory.ts"; -import { ScreenLayout } from "$/common/protocol.ts"; -import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { get_state_value, set_state_value } from "$/common/utils.ts"; -import type { RecipeBook } from "$/common/mod_loader.ts"; -import type { GameServer } from "./game_server.ts"; -import type { ServerPlayer } from "./player.ts"; -import type { Tile } from "./world.ts"; - -// what blocks do, only on the server. blocks without an entry just sit there -export interface BlockBehavior { - // set up a tile for this block. blocks with this get a tile when placed - create_tile?(tile: Tile): void; - // right click, return true if it did something so no block gets placed - on_interact?( - game: GameServer, - block: { x: number; y: number; z: number; id: string }, - player: ServerPlayer, - ): boolean; - on_break?(game: GameServer, tile: Tile, player: ServerPlayer | undefined): void; - on_tick?(game: GameServer, tile: Tile): void; - on_second?(game: GameServer, tile: Tile): void; -} - -export const BLOCK_BEHAVIORS: Record = {}; - -// whatever was inside falls out, like minecraft's Containers.dropContents -function drop_container_contents(game: GameServer, tile: Tile) { - for (const container of Object.values(tile.containers)) { - for (let i = 0; i < container.size; i++) { - const item = container.get_item(i); - if (item) { - container.set_item(i, undefined); - game.pop_item(tile.x, tile.y, tile.z, item); - } - } - } -} - -function hoe_into_hoed_dirt( - game: GameServer, - block: { x: number; y: number; z: number }, - player: ServerPlayer, -): boolean { - if (player.held_item?.type_id !== "bworld:hoe") { - return false; - } - game.set_block(block.x, block.y, block.z, "bworld:hoed_dirt", player); - return true; -} - -BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt }; -BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt }; - -// crops - -// minecraft's bone meal: a crop grows 2 to 5 stages at once, and the bone meal is used up -function grow_with_bone_meal( - game: GameServer, - block: { x: number; y: number; z: number; id: string }, - player: ServerPlayer, -): boolean { - const held = player.held_item; - if (held?.type_id !== "bworld:bone_meal") { - return false; - } - const info = EverythingRegistry.get("blocks", block.id)!; - const value = game.world.get_block_value(block.x, block.y, block.z); - const age = get_state_value(value, info, "age")!; - const max_age = 2 ** info.states!.find((s) => s.name === "age")!.bits - 1; - // fully grown, keep the bone meal - if (age >= max_age) { - return false; - } - - const new_age = Math.min(age + 1, max_age); - game.set_block_state(block.x, block.y, block.z, set_state_value(value, info, "age", new_age)! >>> 16); - const slot = player.inventory.get_slot(player.selected_slot); - slot.amount = slot.amount! - 1; - return true; -} - -BLOCK_BEHAVIORS["bworld:wheat"] = { on_interact: grow_with_bone_meal }; - -// chest - -const CHEST_LAYOUT: ScreenLayout = { - rows: 3, - slots: Array.from({ length: 9 * 3 }, (_, index) => ({ index, x: index % 9, y: Math.floor(index / 9) })), - bars: [], -}; - -BLOCK_BEHAVIORS["bworld:chest"] = { - create_tile(tile) { - tile.containers.main = new Container(9 * 3); - }, - on_interact(game, block, player) { - const tile = game.get_or_create_tile(block.x, block.y, block.z); - game.open_screen(player, { - tile, - container: tile.containers.main, - layout: CHEST_LAYOUT, - properties: () => ({}), - }); - return true; - }, - on_break(game, tile) { - drop_container_contents(game, tile); - }, -}; - -// furnace - -interface FurnaceData { - progress: number; - progress_max: number; - fuel: number; - fuel_max: number; -} - -interface FurnaceRecipe { - output: { id: string; count: number }; - cook_time: number; -} - -function get_recipe(recipes: RecipeBook, input?: ItemStack | undefined): FurnaceRecipe | undefined { - if (!input) { - return; - } - - return recipes.furnace.get(input.type_id); -} - -function can_craft(container: Container, recipe?: FurnaceRecipe) { - if (!recipe) { - return false; - } - - const output = container.get_item(2); - - if (!output) { - return true; - } - - if (output.type_id !== recipe.output.id) { - return false; - } - - return output.amount < output.max_amount; -} - -function get_fuel_value(recipes: RecipeBook, item?: ItemStack | undefined): number { - if (!item) { - return 0; - } - return recipes.fuel.get(item.type_id) ?? 0; -} - -function has_fuel(recipes: RecipeBook, container: Container) { - return get_fuel_value(recipes, container.get_item(1)) > 0; -} - -function consume_fuel(recipes: RecipeBook, container: Container): number { - const fuel = container.get_slot(1)!; - const value = get_fuel_value(recipes, fuel.get_item()); - - if (fuel.has_item()) { - fuel.amount! -= 1; - } - - return value; -} - -function craft(container: Container, recipe: FurnaceRecipe) { - const input = container.get_item(0)!; - const output = container.get_item(2); - - if (!output) { - container.set_item(2, new ItemStack(recipe.output.id, recipe.output.count)); - } else { - output.amount += recipe.output.count; - } - - input.amount -= 1; - container.set_item(0, input.amount > 0 ? input : undefined); -} - -const FURNACE_LAYOUT: ScreenLayout = { - rows: 3, - slots: [ - { index: 0, x: 3.5, y: 0 }, - { index: 1, x: 3.5, y: 2 }, - { index: 2, x: 5.5, y: 1, output: true }, - ], - bars: [ - { - x: 3.5, - y: 1, - value: "fuel", - max: "fuel_max", - direction: "up", - empty_texture: "bworld:fire_empty", - full_texture: "bworld:fire_full", - }, - { - x: 4.5, - y: 1, - value: "progress", - max: "progress_max", - direction: "right", - empty_texture: "bworld:arrow_empty", - full_texture: "bworld:arrow_full", - }, - ], -}; - -BLOCK_BEHAVIORS["bworld:furnace"] = { - create_tile(tile) { - tile.containers.main = new Container(3); - tile.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0 } satisfies FurnaceData; - }, - on_interact(game, block, player) { - const tile = game.get_or_create_tile(block.x, block.y, block.z); - const data = tile.data as unknown as FurnaceData; - game.open_screen(player, { - tile, - container: tile.containers.main, - layout: FURNACE_LAYOUT, - properties: () => ({ ...data }), - }); - return true; - }, - on_break(game, tile) { - drop_container_contents(game, tile); - }, - on_tick(game, tile) { - const data = tile.data as unknown as FurnaceData; - const container = tile.containers.main; - - const input = container.get_item(0); - const recipe = get_recipe(game.recipes, input); - - // burn fuel - if (data.fuel > 0) { - data.fuel -= 1; - } - - if (!can_craft(container, recipe)) { - data.progress = 0; - return; - } - - // refuel - if (data.fuel === 0 && has_fuel(game.recipes, container)) { - data.fuel = consume_fuel(game.recipes, container); - data.fuel_max = data.fuel; - } - - // cook - if (data.fuel > 0 && recipe) { - data.progress_max = recipe.cook_time; - data.progress += 1; - - if (data.progress >= recipe.cook_time) { - data.progress = 0; - craft(container, recipe); - } - } - }, -}; - -// items that still need code, until they're components in mods/bworld (phase 2 in MODS.md) - -export interface WateringCanData { - water: number; - max_water: number; -} - -export function add_base_item_hooks() { - const watering_can = EverythingRegistry.get>("items", "bworld:watering_can"); - if (watering_can) { - watering_can.on_create = (item) => { - item.data = { water: 0, max_water: 32 }; - }; - watering_can.get_lore = (item) => `Water: ${item.data?.water}/${item.data?.max_water}`; - } -} diff --git a/server/game/game_server.ts b/server/game/game_server.ts index 0eeec7d..df3fef9 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -26,7 +26,6 @@ import { } from "$/common/protocol.ts"; import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts"; import type { WorldgenSetup } from "$/common/generation.ts"; -import { BLOCK_BEHAVIORS } from "./blocks.ts"; import { ModRuntime, run_guarded } from "./mod_runtime.ts"; import type { GameLoop } from "./game_loop.ts"; import { consume_recipe_items, update_crafting_result } from "./crafting.ts"; @@ -61,19 +60,12 @@ export interface GameHost { } export interface SavedWorld { - version: 2; + version: 3; seed: string; changes: BlockChange[]; - tiles: { - id: string; - x: number; - y: number; - z: number; - data: Record; - containers: Record; - // a mod block's data, what BlockRef.data holds - mod_data?: unknown; - }[]; + tiles: SavedTile[]; + // every ctx.containers container, by id + containers?: Record; players: Record; // items on the ground entities?: SavedItemEntity[]; @@ -81,6 +73,18 @@ export interface SavedWorld { mod_storage?: Record>; } +interface SavedTile { + id: string; + x: number; + y: number; + z: number; + // what BlockRef.data holds + mod_data?: unknown; + // version 2, when chests and furnaces were engine code: their fields and their items + data?: Record; + containers?: Record; +} + // the loaded mods, see load_mods.ts export interface GameMods { listings: ModListing[]; @@ -102,6 +106,8 @@ export class GameServer { #entities = new Map(); #next_entity_id = 1; #mods: GameMods; + // ctx.containers, saved with the world + containers = new Map(); mods: ModRuntime; recipes: RecipeBook; // what runs tick(), for /tps. tests call tick() themselves @@ -118,20 +124,20 @@ export class GameServer { this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen); this.mods.storage = saved?.mod_storage ?? {}; this.world.load_changes(saved?.changes ?? []); + for (const [id, items] of Object.entries(saved?.containers ?? {})) { + const container = new Container(items.length); + container.load(items); + this.containers.set(id, container); + } for (const tile of saved?.tiles ?? []) { - const containers: Record = {}; - for (const [name, items] of Object.entries(tile.containers)) { - containers[name] = new Container(items.length); - containers[name].load(items); - } - this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers }); + this.world.add_tile({ id: tile.id, x: tile.x, y: tile.y, z: tile.z, mod_data: this.#upgrade_tile(tile) }); } for (const entity of saved?.entities ?? []) { const item = ItemEntity.load(this.#new_entity_id(), entity); this.#entities.set(item.id, item); } this.#saved_players = saved?.players ?? {}; - this.world.dirty = saved?.version !== 2; + this.world.dirty = saved?.version !== 3; this.world.on_block_change = (x, y, z, id, state) => this.#broadcast(state ? { type: "set_block", x, y, z, id, state } : { type: "set_block", x, y, z, id }); @@ -185,28 +191,21 @@ export class GameServer { const second = this.#tick % TICKS_PER_SECOND === 0; for (const tile of [...this.world.tiles.values()]) { - const behavior = BLOCK_BEHAVIORS[tile.id]; + if (tile.mod_data === undefined) { + continue; + } const components = this.mods.components_of(tile.id) .filter(({ component }) => component.on_tick || component.on_second); - if (!behavior?.on_tick && !behavior?.on_second && components.length === 0) { + if (components.length === 0 || !this.#near_any_player(tile.x, tile.z)) { continue; } - if (!this.#near_any_player(tile.x, tile.z)) { - continue; - } - behavior?.on_tick?.(this, tile); - if (second) { - behavior?.on_second?.(this, tile); - } - if (components.length > 0 && tile.mod_data !== undefined) { - const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id); - for (const { mod, id, component, params } of components) { - if (component.on_tick) { - run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA)); - } - if (second && component.on_second) { - run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1)); - } + const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id); + for (const { mod, id, component, params } of components) { + if (component.on_tick) { + run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA)); + } + if (second && component.on_second) { + run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1)); } } // tile data can change on any tick, saving is cheap enough to not track it exactly @@ -235,7 +234,7 @@ export class GameServer { this.#saved_players[player.name] = player.save(); } const saved: SavedWorld = { - version: 2, + version: 3, seed: this.world.seed, changes: this.world.all_changes(), tiles: [...this.world.tiles.values()].map((tile) => ({ @@ -243,12 +242,11 @@ export class GameServer { x: tile.x, y: tile.y, z: tile.z, - data: tile.data, - containers: Object.fromEntries( - Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]), - ), mod_data: tile.mod_data, })), + containers: Object.fromEntries( + [...this.containers].map(([id, container]) => [id, container.to_data()]), + ), players: this.#saved_players, entities: [...this.#entities.values()].map((entity) => entity.save()), mod_storage: this.mods.storage, @@ -365,24 +363,9 @@ export class GameServer { } } - const old_tile = this.world.get_tile(x, y, z); - if (old_tile) { - BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player); - this.world.remove_tile(x, y, z); - for (const other of this.#players.values()) { - if (other.screen?.tile === old_tile) { - this.#close_screen(other); - this.#send(other, { type: "close_screen" }); - } - } - } - + this.world.remove_tile(x, y, z); this.world.set_block(x, y, z, id, state); - if (BLOCK_BEHAVIORS[id]?.create_tile) { - this.get_or_create_tile(x, y, z); - } - if (id !== AIR_ID) { const block = this.mods.block_ref(x, y, z, id); for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) { @@ -396,23 +379,72 @@ export class GameServer { get_or_create_tile(x: number, y: number, z: number): Tile { let tile = this.world.get_tile(x, y, z); if (!tile) { - // blocks placed before tiles were saved have none yet - const id = this.world.get_block_id(x, y, z); - tile = { id, x, y, z, data: {}, containers: {} }; - BLOCK_BEHAVIORS[id]?.create_tile?.(tile); + tile = { id: this.world.get_block_id(x, y, z), x, y, z }; this.world.add_tile(tile); } return tile; } + // containers + + create_container(size: number): { id: string; container: Container } { + const id = crypto.randomUUID(); + const container = new Container(size); + this.containers.set(id, container); + this.world.dirty = true; + return { id, container }; + } + + // anyone looking at it gets their screen closed + delete_container(id: string) { + const container = this.containers.get(id); + if (!container) { + return; + } + this.containers.delete(id); + this.world.dirty = true; + for (const player of this.#players.values()) { + if (player.screen?.container === container) { + this.close_screen(player); + } + } + } + + // screens + open_screen(player: ServerPlayer, screen: OpenScreen) { this.#close_screen(player); player.screen = screen; - this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() }); - player.sent.set("properties", JSON.stringify(screen.properties())); + this.#send(player, { type: "open_screen", layout: screen.layout, properties: { ...screen.properties } }); + player.sent.set("properties", JSON.stringify(screen.properties)); player.sent.delete("screen"); } + // closes it for the player too, not just on the server + close_screen(player: ServerPlayer) { + if (!player.screen) { + return; + } + this.#close_screen(player); + this.#send(player, { type: "close_screen" }); + } + + // version 2 saves had chests and furnaces in the engine, with their fields in data and their items in + // containers.main. the components keep those fields and put the items in a ctx.containers container + #upgrade_tile(tile: SavedTile): unknown { + if (tile.mod_data !== undefined || (!tile.data && !tile.containers)) { + return tile.mod_data; + } + const data: Record = { ...tile.data }; + const items = tile.containers?.main; + if (items) { + const { id, container } = this.create_container(items.length); + container.load(items); + data.container = id; + } + return data; + } + // messages // hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md @@ -623,7 +655,7 @@ export class GameServer { return; } - let handled = BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player) ?? false; + let handled = false; for (const { mod, id: component_id, component, params } of this.mods.components_of(info.id)) { if (component.on_interact) { handled = run_guarded(mod, `${component_id} on_interact`, () => @@ -715,6 +747,11 @@ export class GameServer { if (item && take_output(item, player.cursor)) { container.set_item(index, undefined); } + } else if ( + key === "screen" && player.cursor.item && !(player.screen?.filters.get(index)?.(player.cursor.item) ?? true) + ) { + // the slot doesn't take what the cursor holds + return; } else { click_slot(container, index, player.cursor, button); } @@ -722,6 +759,9 @@ export class GameServer { if (key === "crafting") { update_crafting_result(container, this.recipes.shaped); } + if (key === "screen") { + this.world.dirty = true; + } } #chat(player: ServerPlayer, raw: unknown) { @@ -756,8 +796,16 @@ export class GameServer { this.#send(player, { type: "chat", text: "Usage: /give [count]" }); return; } + // a name without a namespace works when only one mod has an item called that if (!item_id.includes(":")) { - item_id = `bworld:${item_id}`; + const matches = EverythingRegistry.entries("items").map(([id]) => id).filter((id) => + id.endsWith(`:${item_id}`) + ); + if (matches.length > 1) { + this.#send(player, { type: "chat", text: `Which one? ${matches.join(", ")}` }); + return; + } + item_id = matches[0] ?? item_id; } const amount = count === undefined ? 1 : Number(count); if (!EverythingRegistry.get("items", item_id)) { @@ -793,7 +841,9 @@ export class GameServer { } #close_screen(player: ServerPlayer) { + const screen = player.screen; player.screen = undefined; + for (const fn of screen?.on_close ?? []) fn(); player.sent.delete("screen"); player.sent.delete("properties"); @@ -949,7 +999,7 @@ export class GameServer { if (player.screen) { const items = player.screen.container.to_data(); sync("screen", items, () => ({ type: "container", container: "screen", items })); - const properties = player.screen.properties(); + const properties = { ...player.screen.properties }; sync("properties", properties, () => ({ type: "screen_properties", properties })); } } diff --git a/server/game/load_mods.ts b/server/game/load_mods.ts index 9177a4d..af155b3 100644 --- a/server/game/load_mods.ts +++ b/server/game/load_mods.ts @@ -3,7 +3,6 @@ import { register_mod_data } from "$/common/mod_loader.ts"; import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts"; -import { add_base_item_hooks } from "./blocks.ts"; import { GameHost, GameServer } from "./game_server.ts"; import { ModRuntime } from "./mod_runtime.ts"; @@ -23,7 +22,6 @@ export async function start_game( mods: ServerModSource[], ): Promise { const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data }))); - add_base_item_hooks(); const worldgen_scripts = mods.flatMap((mod) => mod.worldgen_url ? [{ mod: mod.listing.id, url: mod.worldgen_url }] : [] diff --git a/server/game/mod_runtime.ts b/server/game/mod_runtime.ts index 08c83f0..74b5545 100644 --- a/server/game/mod_runtime.ts +++ b/server/game/mod_runtime.ts @@ -8,10 +8,12 @@ import type { BlockRef, Command, Container, + ContainerScreenOptions, EventSignal, ItemComponent, ItemStack, Player, + ScreenHandle, ServerAfterEvents, ServerBeforeEvents, ServerContext, @@ -19,7 +21,8 @@ import type { import { ModLoadError } from "$/common/mod_loader.ts"; import { AIR_ID } from "$/common/protocol.ts"; import type { GameServer } from "./game_server.ts"; -import type { ServerPlayer } from "./player.ts"; +import type { OpenScreen, ServerPlayer } from "./player.ts"; +import type { ScreenLayout } from "$/common/protocol.ts"; // a list of handlers that can't break each other: one throwing is logged under its mod and the rest still run export class Signal { @@ -107,6 +110,8 @@ export class ModRuntime { #next_timer = 1; #setting_up = true; #players = new WeakMap(); + // the other way, for apis that take a player + #server_players = new WeakMap(); game!: GameServer; // imports each server script and calls its setup, in load order @@ -152,6 +157,19 @@ export class ModRuntime { ); } } + const lore = components.filter(([component_id]) => + this.item_components.get(component_id)!.component.get_lore + ); + if (lore.length > 0) { + item.get_lore = (stack) => + lore.map(([component_id, params]) => { + const { mod, component } = this.item_components.get(component_id)!; + return run_guarded(mod, `${component_id} get_lore`, () => + component.get_lore!(item_api(stack), params)); + }).filter((line) => + typeof line === "string" + ).join("\n"); + } // items are created all over the engine, hook their creation once here const previous = item.on_create; item.on_create = (stack) => { @@ -231,6 +249,7 @@ export class ModRuntime { if (!api) { api = player_api(this.game, player); this.#players.set(player, api); + this.#server_players.set(api, player); } return api; } @@ -339,6 +358,7 @@ export class ModRuntime { return true; }, get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never, + drop_item: (x, y, z, item) => void game().pop_item(x, y, z, new_stack(item)), is_loaded: () => true, get seed() { return game().world.seed; @@ -355,6 +375,14 @@ export class ModRuntime { return p && this.player(p); }, }, + items: { + exists: (id) => EverythingRegistry.get("items", id) !== undefined, + max_stack: (id) => { + const item = EverythingRegistry.get("items", id); + if (!item) throw new Error(`unknown item ${id}`); + return item.max_stack ?? 64; + }, + }, recipes: { furnace_result: (input) => { const recipe = game().recipes.furnace.get(input); @@ -364,8 +392,27 @@ export class ModRuntime { is_fuel: (item) => game().recipes.fuel.has(item), is_smeltable: (item) => game().recipes.furnace.has(item), }, - containers: not_yet_object(mod, "containers", "step 7 in MODS.md"), - ui: not_yet_object(mod, "ui", "step 7 in MODS.md"), + containers: { + create: (size) => { + if (!Number.isInteger(size) || size < 1 || size > MAX_CONTAINER_SIZE) { + throw new Error(`a container has 1 to ${MAX_CONTAINER_SIZE} slots, not ${size}`); + } + const { id, container } = game().create_container(size); + return container_api(game(), id, container); + }, + get: (id) => { + const container = game().containers.get(id); + return container && container_api(game(), id, container); + }, + delete: (id) => game().delete_container(id), + }, + ui: { + open_container: (player, options) => this.#open_container(mod, player, options), + message_form: not_yet(mod, "ui.message_form", "step 7 in MODS.md"), + action_form: not_yet(mod, "ui.action_form", "step 7 in MODS.md"), + modal_form: not_yet(mod, "ui.modal_form", "step 7 in MODS.md"), + open_screen: not_yet(mod, "ui.open_screen", "step 7 in MODS.md"), + }, net: not_yet_object(mod, "net", "step 8 in MODS.md"), storage: { get: (key) => structuredClone(this.storage[mod]?.[key]) as never, @@ -383,6 +430,78 @@ export class ModRuntime { return ctx; } + #open_container(mod: string, player: Player, options: ContainerScreenOptions): ScreenHandle { + const game = this.game; + const server_player = this.#server_players.get(player); + if (!server_player || !game.players().includes(server_player)) { + throw new Error(`${player.name} isn't online`); + } + const container = game.containers.get(options.container.id); + if (!container) { + throw new Error("open_container needs a container from ctx.containers"); + } + + const filters = new Map boolean>(); + for (const { slot, filter } of options.layout) { + if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) { + throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`); + } + if (filter === "smeltable") { + filters.set(slot, (item) => game.recipes.furnace.has(item.type_id)); + } else if (filter === "fuel") { + filters.set(slot, (item) => game.recipes.fuel.has(item.type_id)); + } else if (typeof filter === "function") { + filters.set(slot, (item) => run_guarded(mod, "a slot filter", () => filter(item_api(item))) === true); + } + } + + const bars = options.bars ?? []; + const bottom = Math.max(0, ...options.layout.map((s) => s.y + 1), ...bars.map((b) => b.y + 1)); + const layout: ScreenLayout = { + rows: options.rows ?? Math.ceil(bottom), + slots: options.layout.map(({ slot, x, y, output_only }) => + output_only ? { index: slot, x, y, output: true } : { index: slot, x, y } + ), + bars: bars.map(({ x, y, value, max, direction, empty_texture, full_texture }) => ({ + x, + y, + value, + max, + direction, + empty_texture, + full_texture, + })), + }; + + let open = true; + const screen: OpenScreen = { container, layout, properties: {}, filters, on_close: [() => open = false] }; + game.open_screen(server_player, screen); + + return { + player, + get open() { + return open; + }, + set_property(id, value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`property ${id} must be a number, not ${value}`); + } + screen.properties[id] = value; + }, + update: not_yet(mod, "update", "custom screens are step 7 in MODS.md"), + close() { + if (server_player.screen === screen) game.close_screen(server_player); + }, + on_close(fn) { + if (!open) { + run_guarded(mod, "a screen on_close", fn); + return; + } + screen.on_close.push(() => run_guarded(mod, "a screen on_close", fn)); + }, + }; + } + #add_timer(mod: string, fn: () => void, ticks: number, every?: number) { const handle = this.#next_timer++; this.#timers.set(handle, { mod, fn, at: this.game.current_tick + Math.max(0, ticks), every }); @@ -426,6 +545,8 @@ function not_yet_object(mod: string, name: string, where: string): T { }) as T; } +const MAX_CONTAINER_SIZE = 256; + // the engine's item stacks as mods see them: { id, count, data } export function item_api(stack: EngineItemStack): ItemStack { return { @@ -454,22 +575,32 @@ function new_stack(item: ItemStack): EngineItemStack { return stack; } -function player_api(game: GameServer, player: ServerPlayer): Player { - const inventory: Container = { - id: `player:${player.id}`, - size: player.inventory.size, +// an engine container as mods see it +function container_api(game: GameServer, id: string, container: EngineContainer): Container { + return { + id, + size: container.size, get: (slot) => { - const stack = player.inventory.get_item(slot); + const stack = container.get_item(slot); return stack && item_api(stack); }, - set: (slot, item) => player.inventory.set_item(slot, item ? new_stack(item) : undefined), + set: (slot, item) => { + if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) { + throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`); + } + container.set_item(slot, item ? new_stack(item) : undefined); + }, add: (item) => { const stack = new_stack(item); - const left = player.inventory.add_item(stack); + const left = container.add_item(stack); return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined; }, - on_change: (fn) => game.mods.watch_container(running_mod || "unknown", player.inventory, fn), + on_change: (fn) => game.mods.watch_container(running_mod || "unknown", container, fn), }; +} + +function player_api(game: GameServer, player: ServerPlayer): Player { + const inventory = container_api(game, `player:${player.id}`, player.inventory); return { get id() { diff --git a/server/game/player.ts b/server/game/player.ts index a636790..503e517 100644 --- a/server/game/player.ts +++ b/server/game/player.ts @@ -1,16 +1,18 @@ import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts"; import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts"; -import { Tile } from "./world.ts"; export const INVENTORY_SIZE = 9 * 4; export const CRAFTING_SIZE = 10; -// a screen the server opened for this player, showing a tile's container +// a container screen a mod opened for this player, see ctx.ui.open_container export interface OpenScreen { - tile: Tile; container: Container; layout: ScreenLayout; - properties(): Record; + // what the bars show, synced when they change + properties: Record; + // what can go into each slot, checked when the player clicks + filters: Map boolean>; + on_close: (() => void)[]; } // what's saved about a player between sessions, by name diff --git a/server/game/world.ts b/server/game/world.ts index e29a9ab..18b20d5 100644 --- a/server/game/world.ts +++ b/server/game/world.ts @@ -1,7 +1,6 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts"; -import { Container } from "$/common/inventory.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { LruMap } from "./lru.ts"; @@ -12,15 +11,13 @@ const CHUNK_CACHE_SIZE = 256; // how far from the middle of the world to look for dry land to spawn on, in chunks const SPAWN_SEARCH_CHUNKS = 32; -// a block with state the server keeps, like a chest's items. never sent to clients as is +// a block with data the server keeps, like where a chest's items are. never sent to clients export interface Tile { id: string; x: number; y: number; z: number; - data: Record; - containers: Record; - // a mod block's data, what BlockRef.data holds + // what BlockRef.data holds mod_data?: unknown; } diff --git a/tests/base_game_test.ts b/tests/base_game_test.ts new file mode 100644 index 0000000..269a071 --- /dev/null +++ b/tests/base_game_test.ts @@ -0,0 +1,197 @@ +// 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); +}); diff --git a/tests/fixtures/world_v2.json b/tests/fixtures/world_v2.json new file mode 100644 index 0000000..1dc9fc7 --- /dev/null +++ b/tests/fixtures/world_v2.json @@ -0,0 +1,436 @@ +{ + "version": 2, + "seed": "fixture-seed", + "changes": [ + [ + -3, + 99, + -3, + "bworld:stone" + ], + [ + -3, + 99, + -2, + "bworld:stone" + ], + [ + -3, + 99, + -1, + "bworld:stone" + ], + [ + -2, + 99, + -3, + "bworld:stone" + ], + [ + -2, + 99, + -2, + "bworld:stone" + ], + [ + -2, + 99, + -1, + "bworld:stone" + ], + [ + -1, + 99, + -3, + "bworld:stone" + ], + [ + -1, + 99, + -2, + "bworld:stone" + ], + [ + -1, + 99, + -1, + "bworld:stone" + ], + [ + -3, + 99, + 0, + "bworld:stone" + ], + [ + -3, + 99, + 1, + "bworld:stone" + ], + [ + -3, + 99, + 2, + "bworld:stone" + ], + [ + -3, + 99, + 3, + "bworld:stone" + ], + [ + -2, + 99, + 0, + "bworld:stone" + ], + [ + -2, + 99, + 1, + "bworld:stone" + ], + [ + -2, + 99, + 2, + "bworld:stone" + ], + [ + -2, + 99, + 3, + "bworld:stone" + ], + [ + -1, + 99, + 0, + "bworld:stone" + ], + [ + -1, + 99, + 1, + "bworld:stone" + ], + [ + -1, + 99, + 2, + "bworld:stone" + ], + [ + -1, + 99, + 3, + "bworld:stone" + ], + [ + 0, + 99, + -3, + "bworld:stone" + ], + [ + 0, + 99, + -2, + "bworld:stone" + ], + [ + 0, + 99, + -1, + "bworld:stone" + ], + [ + 1, + 99, + -3, + "bworld:stone" + ], + [ + 1, + 99, + -2, + "bworld:stone" + ], + [ + 1, + 99, + -1, + "bworld:stone" + ], + [ + 2, + 99, + -3, + "bworld:stone" + ], + [ + 2, + 99, + -2, + "bworld:stone" + ], + [ + 2, + 99, + -1, + "bworld:stone" + ], + [ + 3, + 99, + -3, + "bworld:stone" + ], + [ + 3, + 99, + -2, + "bworld:stone" + ], + [ + 3, + 99, + -1, + "bworld:stone" + ], + [ + 0, + 99, + 0, + "bworld:stone" + ], + [ + 0, + 99, + 1, + "bworld:stone" + ], + [ + 0, + 99, + 2, + "bworld:stone" + ], + [ + 0, + 99, + 3, + "bworld:stone" + ], + [ + 1, + 99, + 0, + "bworld:stone" + ], + [ + 1, + 99, + 1, + "bworld:stone" + ], + [ + 1, + 99, + 2, + "bworld:stone" + ], + [ + 1, + 99, + 3, + "bworld:stone" + ], + [ + 2, + 99, + 0, + "bworld:stone" + ], + [ + 2, + 99, + 1, + "bworld:stone" + ], + [ + 2, + 99, + 2, + "bworld:stone" + ], + [ + 2, + 99, + 3, + "bworld:stone" + ], + [ + 3, + 99, + 0, + "bworld:stone" + ], + [ + 3, + 99, + 1, + "bworld:stone" + ], + [ + 3, + 99, + 2, + "bworld:stone" + ], + [ + 3, + 99, + 3, + "bworld:stone" + ], + [ + 1, + 100, + 0, + "bworld:chest" + ], + [ + 2, + 100, + 0, + "bworld:furnace" + ] + ], + "tiles": [ + { + "id": "bworld:chest", + "x": 1, + "y": 100, + "z": 0, + "data": {}, + "containers": { + "main": [ + null, + null, + null, + null, + { + "id": "bworld:coal", + "count": 6 + }, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + } + }, + { + "id": "bworld:furnace", + "x": 2, + "y": 100, + "z": 0, + "data": { + "progress": 50, + "progress_max": 200, + "fuel": 751, + "fuel_max": 1000 + }, + "containers": { + "main": [ + { + "id": "bworld:iron_ore", + "count": 2 + }, + { + "id": "bworld:coal", + "count": 2 + }, + { + "id": "bworld:iron_ingot", + "count": 1 + } + ] + } + } + ], + "players": { + "alice": { + "x": 0.5, + "y": 100, + "z": 0.5, + "yaw": 0, + "pitch": 0, + "selected_slot": 0, + "inventory": [ + { + "id": "bworld:coal", + "count": 2 + }, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + } + }, + "entities": [], + "mod_storage": {} +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 194a20f..44da9b4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -25,6 +25,7 @@ export function mod_sources(mods_dir: string): ServerModSource[] { }, data: { blocks: mod.blocks.map((b) => b.json), + models: mod.models.map((m) => m.json), items: mod.items.map((i) => i.json), recipes: mod.recipes.map((r) => r.json), ores: mod.ores.map((o) => o.json), diff --git a/tests/mod_loading_test.ts b/tests/mod_loading_test.ts index c793797..33cd616 100644 --- a/tests/mod_loading_test.ts +++ b/tests/mod_loading_test.ts @@ -3,7 +3,6 @@ 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 { get_state_value } from "$/common/utils.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts"; import { create_mod } from "$/tools/new_mod.ts"; import { PROTOCOL_VERSION } from "$/common/protocol.ts"; @@ -35,37 +34,6 @@ Deno.test("the base game loads from mods/bworld", async () => { assert(EverythingRegistry.get("items", "bworld:watering_can")?.on_create); }); -Deno.test("bone meal grows wheat until it's fully grown", async () => { - const { game, take, send } = await test_game("mods"); - game.on_connect(1); - send(1, { type: "hello", name: "alice", protocol: PROTOCOL_VERSION }); - send(1, { type: "ready" }); - - let y = CHUNK_HEIGHT - 1; - while (game.world.get_block_nid(1, y, 1) === AIR) y--; - y += 1; - game.set_block(1, y, 1, "bworld:wheat"); - const wheat = EverythingRegistry.get("blocks", "bworld:wheat")!; - const age = () => get_state_value(game.world.get_block_value(1, y, 1), wheat, "age")!; - assertEquals(age(), 0); - - send(1, { type: "chat", text: "/give bworld:bone_meal 8" }); - const slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "bworld:bone_meal"); - send(1, { type: "select_slot", slot }); - send(1, { type: "move", x: 2.5, y, z: 2.5, yaw: 0, pitch: 0 }); - - // 2 to 5 stages at a time - send(1, { type: "use_block", x: 1, y, z: 1, face: "top" }); - assert(age() >= 2 && age() <= 5, `age ${age()}`); - // at most 3 more uses to reach 7, then it stops using bone meal - for (let i = 0; i < 5; i++) send(1, { type: "use_block", x: 1, y, z: 1, face: "top" }); - assertEquals(age(), 7); - const left = inventory_of(take(1))[slot]?.count; - assert(left >= 4 && left <= 6, `${left} bone meal left`); - // nothing got placed on top - assertEquals(game.world.get_block_id(1, y + 1, 1), "bworld:air"); -}); - 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")) { diff --git a/tests/server_scripts_test.ts b/tests/server_scripts_test.ts index f322d5c..0135eb5 100644 --- a/tests/server_scripts_test.ts +++ b/tests/server_scripts_test.ts @@ -24,6 +24,28 @@ export function setup(ctx: ServerContext) { on_tick() { throw new Error("broken on purpose"); }, on_click(block, _params, player) { log("click", [block.id, player.name]); }, }); + // a box that only takes coal, counts how often it's opened, and gives up its items when broken + ctx.components.register_block("testmod:coal_box", { + on_interact(block, _params, player) { + block.data ??= { container: ctx.containers.create(2).id, opened: 0 }; + block.data.opened += 1; + const screen = ctx.ui.open_container(player, { + container: ctx.containers.get(block.data.container)!, + layout: [{ slot: 0, x: 0, y: 0, filter: (item) => item.id === "bworld:coal" }, { slot: 1, x: 1, y: 0 }], + }); + screen.set_property("opened", block.data.opened); + screen.on_close(() => log("closed", screen.open)); + return true; + }, + on_break(block) { + const container = ctx.containers.get(block.data.container)!; + for (let slot = 0; slot < container.size; slot++) { + const item = container.get(slot); + if (item) ctx.world.drop_item(block.x, block.y, block.z, item); + } + ctx.containers.delete(container.id); + }, + }); ctx.components.register_item("testmod:wand", { on_use(item, params: { power: number }, player) { log("use", [item.id, params.power, player.name]); }, }); @@ -84,6 +106,10 @@ function test_mods() { `${mod}/blocks/broken.json`, block("testmod:broken", { components: { "testmod:broken": {} } }), ); + Deno.writeTextFileSync( + `${mod}/blocks/coal_box.json`, + block("testmod:coal_box", { interactive: true, components: { "testmod:coal_box": {} } }), + ); Deno.writeTextFileSync( `${mod}/blocks/lamp.json`, block("testmod:lamp", { @@ -227,3 +253,50 @@ Deno.test("/tps reports the loop, and mods can't take engine commands", async () assert(error.includes("/tps belongs to the engine"), error); Deno.removeSync(clash, { recursive: true }); }); + +Deno.test("container screens: filters, properties, closing, saving, and dropping what's inside", async () => { + const { game, send, take, log, dir } = await setup(); + game.set_block(1, 100, 0, "testmod:coal_box"); + send(1, { type: "chat", text: "/give bworld:coal 3" }); + send(1, { type: "chat", text: "/give bworld:stone 1" }); + take(1); + + send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" }); + let messages = take(1); + const opened = messages.find((m) => m.type === "open_screen"); + assertEquals(opened.layout, { rows: 1, slots: [{ index: 0, x: 0, y: 0 }, { index: 1, x: 1, y: 0 }], bars: [] }); + assertEquals(messages.find((m) => m.type === "screen_properties").properties, { opened: 1 }); + + // stone doesn't go into the coal slot, but does into the other one. coal goes in + const screen = (messages: { type: string; container?: string; items?: unknown[] }[]) => + messages.filter((m) => m.type === "container" && m.container === "screen").at(-1)?.items; + send(1, { type: "click", container: "inventory", index: 1, button: 0 }); + send(1, { type: "click", container: "screen", index: 0, button: 0 }); + send(1, { type: "click", container: "screen", index: 1, button: 0 }); + send(1, { type: "click", container: "inventory", index: 0, button: 0 }); + send(1, { type: "click", container: "screen", index: 0, button: 0 }); + messages = take(1); + assertEquals(screen(messages), [{ id: "bworld:coal", count: 3 }, { id: "bworld:stone", count: 1 }]); + + send(1, { type: "close_screen" }); + assertEquals(log().closed, [false]); + + // the items and the block's data are saved + const loaded = await test_game(dir, 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" }); + messages = loaded.take(1); + assertEquals(screen(messages), [{ id: "bworld:coal", count: 3 }, { id: "bworld:stone", count: 1 }]); + assertEquals(messages.find((m) => m.type === "screen_properties").properties, { opened: 2 }); + + // breaking it closes the screen, runs on_close, and drops what was inside + loaded.send(1, { type: "break_block", x: 1, y: 100, z: 0 }); + assert(loaded.take(1).some((m) => m.type === "close_screen")); + const logged = loaded.game.mods.storage.testmod.log as Record; + assertEquals(logged.closed, [false, false]); + const dropped = loaded.game.item_entities().map((e) => e.item.to_data().id).sort(); + assertEquals(dropped, ["bworld:coal", "bworld:stone"]); + assertEquals(loaded.game.containers.size, 0); + Deno.removeSync(dir, { recursive: true }); +});