Smithing table

This commit is contained in:
2026-09-25 23:48:29 -03:00
parent 9237152aa1
commit 8f32a59bd6
15 changed files with 279 additions and 13 deletions
+22 -4
View File
@@ -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.
+6
View File
@@ -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 {
+4
View File
@@ -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;
+18 -2
View File
@@ -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;
}
+12
View File
@@ -50,6 +50,7 @@ export class RecipeBook {
shaped: GridRecipe[] = [];
furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>();
fuel = new Map<string, number>();
smithing: Extract<RecipeJson, { type: "smithing" }>[] = [];
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);
+21
View File
@@ -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": {}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"item": {
"id": "bworld:stone_pickaxe",
"texture": "bworld:stone_pickaxe",
"tool": "pickaxe"
}
}
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "smithing",
"tool": "bworld:wood_pickaxe",
"material": {
"id": "bworld:stone",
"count": 5
},
"result": "bworld:stone_pickaxe"
}
}
+76
View File
@@ -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", {
+3 -2
View File
@@ -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 (
+19 -2
View File
@@ -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<number, (item: EngineItemStack) => boolean>();
for (const { slot, filter } of options.layout) {
const on_take = new Map<number, (item: EngineItemStack) => 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 {
+2
View File
@@ -12,6 +12,8 @@ export interface OpenScreen {
properties: Record<string, number>;
// what can go into each slot, checked when the player clicks
filters: Map<number, (item: ItemStack) => boolean>;
// asked before a player takes from an output slot
on_take: Map<number, (item: ItemStack) => boolean>;
on_close: (() => void)[];
}
+67
View File
@@ -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);
});
+3 -3
View File
@@ -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);
+6
View File
@@ -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) {