diff --git a/MODS.md b/MODS.md index a4ec82b..223c5d5 100644 --- a/MODS.md +++ b/MODS.md @@ -349,8 +349,23 @@ clients as part of the container it's in, so client screens can show it. | `type` | Fields | Notes | | --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Fits anywhere in the 3×3 grid, like the recipes in `server/game/crafting.ts` | -| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as `FURNACE_RECIPES` in `server/game/blocks.ts` | -| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `server/game/blocks.ts` | +| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Used by the furnace | +| `fuel` | `item` id, `burn_time` in ticks | Used by the furnace | +| `smithing` | `tool` item id, `material` `{ id, count }`, optional `addition` item id, `result` item id | Used by the smithing table: upgrades the tool, keeping its data. Uses up the tool, `count` of the material and the addition | + +A smithing recipe, `recipes/smithing_stone_pickaxe.json`: + +```json +{ + "format_version": 1, + "recipe": { + "type": "smithing", + "tool": "bworld:wood_pickaxe", + "material": { "id": "bworld:stone", "count": 5 }, + "result": "bworld:stone_pickaxe" + } +} +``` A shaped recipe, for the crafting grid in the player's inventory screen: @@ -366,7 +381,8 @@ A shaped recipe, for the crafting grid in the player's inventory screen: } ``` -Two furnace recipes with the same `input`, or two shaped recipes with the same pattern and key, are a load error. Server +Two furnace recipes with the same `input`, two smithing recipes with the same tool, material and addition, or two +shaped recipes with the same pattern and key, are a load error. Server scripts can also register recipe types of their own through `ctx.recipes`. ## Server scripts @@ -655,7 +671,9 @@ ctx.components.register_block("copper_tools:smelter", { 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. - `output_only` slots can only be taken from, all at once, like the furnace result. + `output_only` slots can only be taken from, all at once, like the furnace result. Their `on_take(item)` runs when a + player takes from one, before they get it: return `false` to stop them, or use up the inputs there so they're gone in + the same step (the smithing table does this). - `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. diff --git a/common/inventory.ts b/common/inventory.ts index 347fb25..0aa292c 100644 --- a/common/inventory.ts +++ b/common/inventory.ts @@ -258,6 +258,12 @@ function right_click(container: Container, index: number, cursor: Cursor) { slot.amount = half; } +// whether take_output would take it +export function can_take_output(item: ItemStack, cursor: Cursor): boolean { + const holding = cursor.item; + return !holding || (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount); +} + // output slots (furnace result, crafting result) can only be taken from, all at once // returns whether it was taken export function take_output(item: ItemStack, cursor: Cursor): boolean { diff --git a/common/mod_api/server.ts b/common/mod_api/server.ts index 870beb4..392cb7a 100644 --- a/common/mod_api/server.ts +++ b/common/mod_api/server.ts @@ -191,6 +191,8 @@ export interface RecipeApi { fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel is_fuel(item: Id): boolean; is_smeltable(item: Id): boolean; + // the smithing recipe for these items, whatever the material's count. addition is the optional third item + smithing(tool: Id, material: Id, addition?: Id): { result: Id; material_count: number } | undefined; } // guis @@ -233,6 +235,8 @@ export interface ContainerScreenOptions { filter?: SlotFilter; // can only be taken from, all at once, like the furnace result output_only?: boolean; + // runs when a player takes from an output_only slot, before they get it. false stops them taking it + on_take?: (item: ItemStack) => boolean; }[]; // height of the screen's area in slots, fits the layout and bars when not set rows?: number; diff --git a/common/mod_data.ts b/common/mod_data.ts index a38fa62..de28353 100644 --- a/common/mod_data.ts +++ b/common/mod_data.ts @@ -78,7 +78,15 @@ export type RecipeJson = result: { id: string; count: number }; } | { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number } - | { type: "fuel"; item: string; burn_time: number }; + | { type: "fuel"; item: string; burn_time: number } + // upgrades a tool at a smithing table, with some of a material and maybe one more item + | { + type: "smithing"; + tool: string; + material: { id: string; count: number }; + addition?: string; + result: string; + }; // the crafting grid's format, see server/game/crafting.ts export interface GridRecipe { @@ -447,8 +455,16 @@ export function validate_recipe(json: unknown): Problems { problems.push("burn_time must be a positive number of ticks"); } break; + case "smithing": + problems.push( + ...validate_id(json.tool, "tool"), + ...validate_stack(json.material, "material"), + ...validate_id(json.result, "result"), + ); + if (json.addition !== undefined) problems.push(...validate_id(json.addition, "addition")); + break; default: - problems.push('type must be "shaped", "furnace" or "fuel"'); + problems.push('type must be "shaped", "furnace", "fuel" or "smithing"'); } return problems; } diff --git a/common/mod_loader.ts b/common/mod_loader.ts index 7502ab3..f556966 100644 --- a/common/mod_loader.ts +++ b/common/mod_loader.ts @@ -50,6 +50,7 @@ export class RecipeBook { shaped: GridRecipe[] = []; furnace = new Map(); fuel = new Map(); + smithing: Extract[] = []; ores: OreJson[] = []; } @@ -98,6 +99,17 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe case "fuel": recipes.fuel.set(recipe.item, recipe.burn_time); break; + case "smithing": + if ( + recipes.smithing.some((other) => + other.tool === recipe.tool && other.material.id === recipe.material.id && + other.addition === recipe.addition + ) + ) { + fail(`two smithing recipes for ${recipe.tool} with ${recipe.material.id}`); + } + recipes.smithing.push(recipe); + break; } } recipes.ores.push(...data.ores); diff --git a/mods/bworld/blocks/smithing_table.json b/mods/bworld/blocks/smithing_table.json new file mode 100644 index 0000000..dfc17bd --- /dev/null +++ b/mods/bworld/blocks/smithing_table.json @@ -0,0 +1,21 @@ +{ + "format_version": 1, + "block": { + "id": "bworld:smithing_table", + "textures": { + "top": "bworld:smithing_table_top", + "bottom": "bworld:smithing_table_bottom", + "front": "bworld:smithing_table_front", + "side": "bworld:smithing_table_side" + }, + "mining": { + "toughness": 5, + "tool": "axe" + }, + "drops": "bworld:smithing_table", + "interactive": true, + "components": { + "bworld:smithing_table": {} + } + } +} diff --git a/mods/bworld/items/stone_pickaxe.json b/mods/bworld/items/stone_pickaxe.json new file mode 100644 index 0000000..4a5cfcd --- /dev/null +++ b/mods/bworld/items/stone_pickaxe.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:stone_pickaxe", + "texture": "bworld:stone_pickaxe", + "tool": "pickaxe" + } +} diff --git a/mods/bworld/recipes/smithing_stone_pickaxe.json b/mods/bworld/recipes/smithing_stone_pickaxe.json new file mode 100644 index 0000000..febad94 --- /dev/null +++ b/mods/bworld/recipes/smithing_stone_pickaxe.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "recipe": { + "type": "smithing", + "tool": "bworld:wood_pickaxe", + "material": { + "id": "bworld:stone", + "count": 5 + }, + "result": "bworld:stone_pickaxe" + } +} diff --git a/mods/bworld/scripts/server.ts b/mods/bworld/scripts/server.ts index 58f56ec..dbc8f2f 100644 --- a/mods/bworld/scripts/server.ts +++ b/mods/bworld/scripts/server.ts @@ -5,6 +5,7 @@ export function setup(ctx: ServerContext) { register_hoeable(ctx); register_storage(ctx); register_furnace(ctx); + register_smithing_table(ctx); register_crop(ctx); register_watering_can(ctx); } @@ -196,6 +197,81 @@ function register_furnace(ctx: ServerContext) { }); } +// smithing table: a tool, some of a material and maybe a third item make a better tool, see the smithing recipes. +// every player gets their own slots, and what's left in them goes back to the player when they close it + +const TOOL = 0; +const MATERIAL = 1; +const ADDITION = 2; +const RESULT = 3; + +function register_smithing_table(ctx: ServerContext) { + ctx.components.register_block("bworld:smithing_table", { + on_interact(_block, _params, player) { + const container = ctx.containers.create(4); + + // the recipe the inputs make, with enough material + const match = () => { + const tool = container.get(TOOL); + const material = container.get(MATERIAL); + const addition = container.get(ADDITION); + if (!tool || !material) return undefined; + const recipe = ctx.recipes.smithing(tool.id, material.id, addition?.id); + if (!recipe || material.count < recipe.material_count) return undefined; + return { ...recipe, tool, material, addition }; + }; + + // the result slot shows what taking it would give, the upgraded tool keeps the old one's data + const show_result = () => { + const recipe = match(); + const shown = container.get(RESULT); + if (!recipe) { + if (shown) container.set(RESULT, undefined); + } else if (shown?.id !== recipe.result) { + container.set(RESULT, { id: recipe.result, count: 1, data: recipe.tool.data }); + } + }; + const stop = container.on_change((slot) => { + if (slot !== RESULT) show_result(); + }); + + const screen = ctx.ui.open_container(player, { + container, + layout: [ + { slot: TOOL, x: 1.5, y: 0.5 }, + { slot: MATERIAL, x: 2.5, y: 0.5 }, + { slot: ADDITION, x: 3.5, y: 0.5 }, + { + slot: RESULT, + x: 5.5, + y: 0.5, + output_only: true, + // the inputs are used up in the same step, so they can't be taken out after + on_take(item) { + const recipe = match(); + if (!recipe || recipe.result !== item.id) return false; + recipe.tool.count -= 1; + recipe.material.count -= recipe.material_count; + if (recipe.addition) recipe.addition.count -= 1; + return true; + }, + }, + ], + rows: 2, + }); + screen.on_close(() => { + stop(); + for (const slot of [TOOL, MATERIAL, ADDITION]) { + const item = container.get(slot); + if (item) player.give_item(item.id, item.count, item.data); + } + ctx.containers.delete(container.id); + }); + return true; + }, + }); +} + // 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", { diff --git a/server/game/game_server.ts b/server/game/game_server.ts index df3fef9..fde5735 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -12,7 +12,7 @@ import { TICKS_PER_SECOND, } from "$/common/constants.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts"; +import { can_take_output, click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts"; import { AIR_ID, BlockChange, @@ -744,7 +744,8 @@ export class GameServer { } } else if (key === "screen" && player.screen?.layout.slots.find((s) => s.index === index)?.output) { const item = container.get_item(index); - if (item && take_output(item, player.cursor)) { + const allowed = (item: ItemStack) => player.screen?.on_take.get(index)?.(item) ?? true; + if (item && can_take_output(item, player.cursor) && allowed(item) && take_output(item, player.cursor)) { container.set_item(index, undefined); } } else if ( diff --git a/server/game/mod_runtime.ts b/server/game/mod_runtime.ts index 74b5545..37fab4a 100644 --- a/server/game/mod_runtime.ts +++ b/server/game/mod_runtime.ts @@ -391,6 +391,12 @@ export class ModRuntime { fuel_value: (item) => game().recipes.fuel.get(item) ?? 0, is_fuel: (item) => game().recipes.fuel.has(item), is_smeltable: (item) => game().recipes.furnace.has(item), + smithing: (tool, material, addition) => { + const recipe = game().recipes.smithing.find((r) => + r.tool === tool && r.material.id === material && r.addition === addition + ); + return recipe && { result: recipe.result, material_count: recipe.material.count }; + }, }, containers: { create: (size) => { @@ -442,7 +448,11 @@ export class ModRuntime { } const filters = new Map boolean>(); - for (const { slot, filter } of options.layout) { + const on_take = new Map boolean>(); + for (const { slot, filter, on_take: take } of options.layout) { + if (take) { + on_take.set(slot, (item) => run_guarded(mod, "an on_take", () => take(item_api(item))) === true); + } if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) { throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`); } @@ -474,7 +484,14 @@ export class ModRuntime { }; let open = true; - const screen: OpenScreen = { container, layout, properties: {}, filters, on_close: [() => open = false] }; + const screen: OpenScreen = { + container, + layout, + properties: {}, + filters, + on_take, + on_close: [() => open = false], + }; game.open_screen(server_player, screen); return { diff --git a/server/game/player.ts b/server/game/player.ts index 503e517..bde6769 100644 --- a/server/game/player.ts +++ b/server/game/player.ts @@ -12,6 +12,8 @@ export interface OpenScreen { properties: Record; // what can go into each slot, checked when the player clicks filters: Map boolean>; + // asked before a player takes from an output slot + on_take: Map boolean>; on_close: (() => void)[]; } diff --git a/tests/base_game_test.ts b/tests/base_game_test.ts index 269a071..334088a 100644 --- a/tests/base_game_test.ts +++ b/tests/base_game_test.ts @@ -195,3 +195,70 @@ Deno.test("chests and furnaces from a world saved before they were mod code keep 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); +}); diff --git a/tests/mod_loading_test.ts b/tests/mod_loading_test.ts index 33cd616..b5a4e19 100644 --- a/tests/mod_loading_test.ts +++ b/tests/mod_loading_test.ts @@ -23,9 +23,9 @@ const inventory_of = (messages: Msg[]) => Deno.test("the base game loads from mods/bworld", async () => { const { game } = await test_game("mods"); - assertEquals(EverythingRegistry.entries("blocks").length, 20); - // 14 items plus the item forms of the 17 blocks that have one - assertEquals(EverythingRegistry.entries("items").length, 31); + assertEquals(EverythingRegistry.entries("blocks").length, 21); + // 15 items plus the item forms of the 18 blocks that have one + assertEquals(EverythingRegistry.entries("items").length, 33); assertEquals(game.recipes.shaped.length, 5); assertEquals(game.recipes.furnace.size, 6); assertEquals(game.recipes.fuel.size, 2); diff --git a/tools/check_mods.ts b/tools/check_mods.ts index 1e5c169..c010e62 100644 --- a/tools/check_mods.ts +++ b/tools/check_mods.ts @@ -280,6 +280,12 @@ function check_references(mods: LoadedMod[]) { case "fuel": uses(file, json.item, "item"); break; + case "smithing": + uses(file, json.tool, "item"); + uses(file, json.material.id, "item"); + if (json.addition) uses(file, json.addition, "item"); + uses(file, json.result, "item"); + break; } } for (const { file, json } of mod.ores) {