diff --git a/package.json b/package.json index 73a508f..5bee1a7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "site:preview": "cd website && bun run preview", "typecheck": "tsc --noEmit", "check": "bun run typecheck", - "test": "bun tests/liquid.test.ts && bun tests/raycast.test.ts && bun tests/responsiveness.test.ts", + "test": "bun tests/liquid.test.ts && bun tests/raycast.test.ts && bun tests/responsiveness.test.ts && bun tests/recipes.test.ts", "test:light": "bun scripts/lighttest.ts", "screenshot": "bun scripts/screenshot.ts" }, diff --git a/src/engine/Input.ts b/src/engine/Input.ts index b765c5e..d47495c 100644 --- a/src/engine/Input.ts +++ b/src/engine/Input.ts @@ -12,8 +12,14 @@ export class Input { private readonly keys = new Set(); private mouseDX = 0; private mouseDY = 0; + private lastMouseMotionAt = -Infinity; + private lastPointerMotionAt = -Infinity; private breakQueued = false; private placeQueued = false; + private lastBreakQueueAt = -Infinity; + private lastPlaceQueueAt = -Infinity; + private lastBreakDownAt = -Infinity; + private lastPlaceDownAt = -Infinity; private _leftHeld = false; private _rightHeld = false; private lastSpaceTap = 0; @@ -37,6 +43,13 @@ export class Input { window.addEventListener("keydown", this.handleKeyDown); window.addEventListener("keyup", this.handleKeyUp); window.addEventListener("mousemove", this.handleMouseMove); + // Pointer events are more reliable than legacy mouse events in some iframe + // / pointer-lock transitions. Survival mining depends on the held-button + // state, so mirror mouse down/up through pointer down/up too. + window.addEventListener("pointerdown", this.handlePointerDown, true); + window.addEventListener("pointermove", this.handlePointerMove); + window.addEventListener("pointerup", this.handlePointerUp); + window.addEventListener("pointercancel", this.handlePointerCancel); // mousedown is bound to window (capture phase) — NOT the canvas — so clicks // are caught even if an overlay element happens to sit above the canvas. window.addEventListener("mousedown", this.handleMouseDown, true); @@ -61,6 +74,10 @@ export class Input { window.removeEventListener("keydown", this.handleKeyDown); window.removeEventListener("keyup", this.handleKeyUp); window.removeEventListener("mousemove", this.handleMouseMove); + window.removeEventListener("pointerdown", this.handlePointerDown, true); + window.removeEventListener("pointermove", this.handlePointerMove); + window.removeEventListener("pointerup", this.handlePointerUp); + window.removeEventListener("pointercancel", this.handlePointerCancel); window.removeEventListener("mousedown", this.handleMouseDown, true); window.removeEventListener("mouseup", this.handleMouseUp); window.removeEventListener("click", this.handleClick, true); @@ -77,7 +94,7 @@ export class Input { if (t && t.closest && t.closest(".screen:not([hidden])")) return; e.preventDefault(); dbg("contextmenu (robust right-click) -> placeQueued"); - this.placeQueued = true; + if (performance.now() - this.lastPlaceDownAt > 700) this.queuePlace(); }; private handleClick = (e: MouseEvent): void => { @@ -86,7 +103,7 @@ export class Input { const t = e.target as Element | null; if (t && t.closest && t.closest(".screen:not([hidden])")) return; dbg("click (robust left-click) -> breakQueued"); - this.breakQueued = true; + if (performance.now() - this.lastBreakDownAt > 700) this.queueBreak(); }; private handleAuxClick = (e: MouseEvent): void => { @@ -97,10 +114,22 @@ export class Input { if (t && t.closest && t.closest(".screen:not([hidden])")) return; if (e.button === 2) { dbg("auxclick (robust right-click) -> placeQueued"); - this.placeQueued = true; + if (performance.now() - this.lastPlaceDownAt > 700) this.queuePlace(); } }; + private queueBreak(): void { + const now = performance.now(); + if (now - this.lastBreakQueueAt > 180) this.breakQueued = true; + this.lastBreakQueueAt = now; + } + + private queuePlace(): void { + const now = performance.now(); + if (now - this.lastPlaceQueueAt > 180) this.placeQueued = true; + this.lastPlaceQueueAt = now; + } + private handleKeyDown = (e: KeyboardEvent): void => { // Don't capture game keys while the user is typing in a form field. const el = document.activeElement; @@ -133,11 +162,62 @@ export class Input { }; private handleMouseMove = (e: MouseEvent): void => { + // Some browsers emit a mouseup during pointer-lock/focus transitions. Keep + // the held-button state recoverable from the browser's current bitfield, + // but never clear it here: pointer-lock mousemove events may report + // buttons=0 even while the physical button is still held. + if ((e.buttons & 1) !== 0) this._leftHeld = true; + if ((e.buttons & 2) !== 0) this._rightHeld = true; if (!this._locked) return; + const now = performance.now(); + if (now - this.lastPointerMotionAt < 8) return; + this.lastMouseMotionAt = now; this.mouseDX += e.movementX; this.mouseDY += e.movementY; }; + private handlePointerDown = (e: PointerEvent): void => { + if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; + const t = e.target as Element | null; + if (t && t.closest && t.closest(".screen:not([hidden])")) return; + e.preventDefault(); + if (e.button === 0) { + this.lastBreakDownAt = performance.now(); + this.queueBreak(); + this._leftHeld = true; + } else if (e.button === 2) { + this.lastPlaceDownAt = performance.now(); + this.queuePlace(); + this._rightHeld = true; + } + if (!this._locked && e.button === 0) this.requestLock(); + }; + + private handlePointerMove = (e: PointerEvent): void => { + if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; + if ((e.buttons & 1) !== 0) this._leftHeld = true; + if ((e.buttons & 2) !== 0) this._rightHeld = true; + if (!this._locked) return; + // Some iframe/pointer-lock paths report movement on pointermove but not on + // mousemove while a button is held. + const now = performance.now(); + if (now - this.lastMouseMotionAt < 8) return; + this.lastPointerMotionAt = now; + this.mouseDX += e.movementX; + this.mouseDY += e.movementY; + }; + + private handlePointerUp = (e: PointerEvent): void => { + if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; + if (e.button === 0) this._leftHeld = false; + else if (e.button === 2) this._rightHeld = false; + }; + + private handlePointerCancel = (): void => { + this._leftHeld = false; + this._rightHeld = false; + }; + private handleMouseDown = (e: MouseEvent): void => { const t = e.target as Element | null; const describe = (el: Element | null): string => { @@ -154,13 +234,16 @@ export class Input { return; } dbg(`mousedown button=${e.button} locked=${this._locked} target=${describe(t)} pointerLockElement=${document.pointerLockElement ? "yes" : "no"}`); + e.preventDefault(); // Register the interaction FIRST, unconditionally, so clicks always mine/ // place whether or not pointer lock is engaged. if (e.button === 0) { - this.breakQueued = true; + this.lastBreakDownAt = performance.now(); + this.queueBreak(); this._leftHeld = true; } else if (e.button === 2) { - this.placeQueued = true; + this.lastPlaceDownAt = performance.now(); + this.queuePlace(); this._rightHeld = true; } dbg(` queued break=${this.breakQueued} place=${this.placeQueued} leftHeld=${this._leftHeld}`); diff --git a/src/engine/Textures.ts b/src/engine/Textures.ts index 33c774c..30f32c7 100644 --- a/src/engine/Textures.ts +++ b/src/engine/Textures.ts @@ -521,6 +521,38 @@ export function createTextureAtlas(scene: Scene): AtlasResult { } } } + // 43: crafting table top — dark wood with a 3x3 grid of lighter cells + { + const [ox, oy] = off(43); + paintSpeckled(ctx, ox, oy, [104, 70, 40], 14, 70, rand); + // grid lines dividing the tile into a 3x3 of ~5px cells + ctx.fillStyle = "rgb(54,36,20)"; + for (const p of [5, 10]) { + ctx.fillRect(ox, oy + p, TILE_PX, 1); + ctx.fillRect(ox + p, oy, 1, TILE_PX); + } + // faint lighter inset on each cell to read as a work surface + ctx.fillStyle = "rgba(196,150,92,0.35)"; + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + ctx.fillRect(ox + c * 5 + 1, oy + r * 5 + 1, 3, 3); + } + } + } + // 44: crafting table side — plank bands with a darker tool strip + { + const [ox, oy] = off(44); + paintSpeckled(ctx, ox, oy, [150, 104, 60], 12, 70, rand); + // horizontal plank seams + ctx.fillStyle = "rgb(96,64,34)"; + for (const y of [4, 8, 12]) ctx.fillRect(ox, oy + y, TILE_PX, 1); + // a dark recessed strip (the "tool drawer" band) across the middle + ctx.fillStyle = "rgb(70,46,24)"; + ctx.fillRect(ox, oy + 6, TILE_PX, 2); + ctx.fillStyle = "rgba(0,0,0,0.25)"; + ctx.fillRect(ox + 2, oy + 6, 3, 1); + ctx.fillRect(ox + 9, oy + 6, 4, 1); + } // Upload the painted canvas to the GPU. texture.update(false); diff --git a/src/game/Blocks.ts b/src/game/Blocks.ts index 5ceba57..2db35f1 100644 --- a/src/game/Blocks.ts +++ b/src/game/Blocks.ts @@ -61,6 +61,8 @@ const T = { BIRCH_LEAVES: 40, SPRUCE_LEAVES: 41, SNOWY_LEAVES: 42, + CRAFTING_TABLE_TOP: 43, + CRAFTING_TABLE_SIDE: 44, } as const; /** @@ -589,6 +591,23 @@ export const BLOCKS: readonly BlockDef[] = [ liquid: false, light: { lightPassesThrough: true }, }, + { + id: 38, + name: "Crafting Table", + tiles: [ + T.CRAFTING_TABLE_SIDE, + T.CRAFTING_TABLE_SIDE, + T.CRAFTING_TABLE_TOP, + T.WOOD_TOP, + T.CRAFTING_TABLE_SIDE, + T.CRAFTING_TABLE_SIDE, + ], + color: "#8a5a32", + solid: true, + opaque: true, + transparent: false, + liquid: false, + }, ]; export const AIR_BLOCK = 0; @@ -596,6 +615,7 @@ export const WATER_BLOCK = 7; export const WATER_FLOWING_BLOCK = 29; export const CACTUS_BLOCK = 19; export const MUSHROOM_BLOCK = 23; +export const CRAFTING_TABLE_BLOCK = 38; export function isAir(id: BlockId): boolean { return id === AIR_BLOCK; diff --git a/src/game/Game.ts b/src/game/Game.ts index 107dac3..4262092 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -33,6 +33,7 @@ import { isBreakable, isFood, type GameMode, + type ItemId, } from "./Items"; import { Inventory } from "./Inventory"; import { PlayerState } from "./PlayerState"; @@ -57,6 +58,24 @@ const SKY_COLOR_HEX = "#bfe3ff"; const EAT_TIME = 1.6; const INVENTORY_SIZE = 36; const HOTBAR_SIZE = 9; +const DROP_PICKUP_RADIUS = 0.85; +const DROP_FLOAT_AMPLITUDE = 0.08; +const DROP_GRAVITY = 14; +const DROP_TERMINAL_VELOCITY = -8; +const DROP_HALF_SIZE = 0.16; + +interface DroppedItem { + id: ItemId; + count: number; + mesh: Mesh; + baseY: number; + supportX: number; + supportY: number; + supportZ: number; + vy: number; + grounded: boolean; + age: number; +} /** * Top-level orchestrator. Owns the renderer (Babylon Engine), the scene, the @@ -86,6 +105,9 @@ export class Game { private readonly inventory = new Inventory(INVENTORY_SIZE, HOTBAR_SIZE); private readonly stats = new PlayerState(); private readonly invUI: InventoryUI; + private readonly drops: DroppedItem[] = []; + private readonly dropMaterials = new Map(); + private lastDropFullToastAt = -Infinity; private readonly graphics: GraphicsController; private readonly perf: PerfOverlay; private readonly chunkBorders: ChunkBorderOverlay; @@ -352,6 +374,7 @@ export class Game { } private createWorld(seed: string): void { + this.clearDrops(); if (this.lighting) { this.lighting.dispose(); this.lighting = null; @@ -398,17 +421,33 @@ export class Game { /** Load inventory+vitals for this seed, or seed a fresh starter kit. */ private loadOrCreateProgress(): void { const save = loadSave(this.settings.seed); + // Resume in the mode the player last left (if the save recorded one). + if (save?.mode && save.mode !== this.settings.mode) { + this.applySettings({ mode: save.mode }); + } + const mode = this.settings.mode; + this.stats.reset(); + if (mode === "creative") { + // Creative NEVER restores a saved backpack — creative palette pulls are + // ephemeral, so a creative world always opens with the clean starter + // hotbar (see saveState, which also skips persisting creative items). + this.seedInventoryForMode("creative"); + return; + } + // Survival: restore saved progress, else seed the starter survival kit. if (save) { + this.inventory.clear(); // clears backpack + crafting grid this.inventory.load(save.inventory); + this.inventory.loadCrafting(save.crafting); this.stats.load(save.stats); - if (save.mode && save.mode !== this.settings.mode) { - this.applySettings({ mode: save.mode }); - } - return; + } else { + this.seedInventoryForMode("survival"); } - this.inventory.clear(); - this.stats.reset(); - if (this.settings.mode === "creative") { + } + + private seedInventoryForMode(mode: GameMode): void { + this.inventory.clear(); // clears backpack + crafting grid + if (mode === "creative") { for (let i = 0; i < STARTER_CREATIVE_HOTBAR.length && i < HOTBAR_SIZE; i++) { this.inventory.setSlot(i, { id: STARTER_CREATIVE_HOTBAR[i], count: 64 }); } @@ -440,8 +479,12 @@ export class Game { } private quitToMenu(): void { - this.saveState(); + // Close the inventory (returns held + craft-grid items to the backpack) + // BEFORE saving, otherwise those items would be persisted both in the craft + // grid and — next session — re-added to the backpack on close. this.closeInventorySilent(); + this.saveState(); + this.clearDrops(); this.input.exitLock(); this.input.clearTransient(); this.setState("menu"); @@ -487,7 +530,38 @@ export class Game { } private setMode(mode: GameMode): void { - this.applySettings({ mode }); + const prev = this.settings.mode; + if (prev !== mode) { + // Resolve any cursor/craft-grid contents under the previous mode before + // swapping inventories, otherwise creative-held items can leak into the + // restored survival backpack on close. + this.closeInventorySilent(); + this.invUI.clearHeld(); + if (prev === "survival") this.saveState(); + this.applySettings({ mode }); + // Mode inventories are intentionally isolated. Creative palette pulls + // must never leak into survival, and entering creative should always show + // the clean starter hotbar. + if (mode === "creative") { + this.seedInventoryForMode("creative"); + this.stats.reset(); + } else { + const save = loadSave(this.settings.seed); + if (save) { + this.inventory.clear(); + this.inventory.load(save.inventory); + this.inventory.loadCrafting(save.crafting); + this.stats.load(save.stats); + } else { + this.seedInventoryForMode("survival"); + this.stats.reset(); + } + } + this.invUI.refresh(); + this.refreshHud(); + } else { + this.applySettings({ mode }); + } this.hud.showToast(mode === "creative" ? "Creative mode" : "Survival mode"); this.saveState(); } @@ -561,6 +635,7 @@ export class Game { const mode = this.settings.mode; this.player.update(dt, world, this.input, this.settings); world.update(this.player.position.x, this.player.position.z, this.settings.viewDistance, dt * 1000); + this.updateDrops(dt); if (this.breakCooldown > 0) this.breakCooldown -= dt; @@ -757,19 +832,100 @@ export class Game { if (!isBreakable(id)) return; const changed = world.setBlock(x, y, z, 0); dbg(" setBlock -> changed=" + changed); + if (!changed) return; // setBlock already wakes the liquid simulator around the edit, so water // flows into the newly opened space / recedes correctly. if (this.settings.mode === "survival") { const drop = dropForBlock(id); - if (drop !== null) { - const leftover = this.inventory.add(drop, 1); - if (leftover > 0) this.hud.showToast("Inventory full"); - } + if (drop !== null) this.spawnDrop(drop, x + 0.5, y + 0.55, z + 0.5); this.stats.addExhaustion(5); this.refreshHud(); } } + private dropMaterial(id: ItemId): StandardMaterial { + const existing = this.dropMaterials.get(id); + if (existing) return existing; + const mat = new StandardMaterial(`drop-${id}`, this.scene); + const color = getItem(id)?.color ?? "#888888"; + mat.diffuseColor = Color3.FromHexString(color); + mat.emissiveColor = Color3.FromHexString(color).scale(0.18); + mat.specularColor = new Color3(0.08, 0.08, 0.08); + this.dropMaterials.set(id, mat); + return mat; + } + + private spawnDrop(id: ItemId, x: number, y: number, z: number, count = 1): void { + const mesh = MeshBuilder.CreateBox(`drop-${id}`, { size: 0.32 }, this.scene); + mesh.material = this.dropMaterial(id); + mesh.position.set(x, y, z); + mesh.rotation.set(0.25, 0.4, 0.15); + this.drops.push({ id, count, mesh, baseY: y, supportX: 0, supportY: -1, supportZ: 0, vy: 0, grounded: false, age: 0 }); + } + + private updateDrops(dt: number): void { + if (this.drops.length === 0) return; + const p = this.player.position; + for (let i = this.drops.length - 1; i >= 0; i--) { + const d = this.drops[i]; + d.age += dt; + if (!d.grounded) { + d.vy = Math.max(DROP_TERMINAL_VELOCITY, d.vy - DROP_GRAVITY * dt); + d.mesh.position.y += d.vy * dt; + const footY = d.mesh.position.y - DROP_HALF_SIZE; + const belowY = Math.floor(footY); + const bx = Math.floor(d.mesh.position.x); + const bz = Math.floor(d.mesh.position.z); + if (belowY >= 0 && getBlock(this.world!.getBlock(bx, belowY, bz)).solid && footY <= belowY + 1) { + d.baseY = belowY + 1 + DROP_HALF_SIZE; + d.supportX = bx; + d.supportY = belowY; + d.supportZ = bz; + d.mesh.position.y = d.baseY; + d.vy = 0; + d.grounded = true; + } else if (d.mesh.position.y < -8) { + d.mesh.dispose(); + this.drops.splice(i, 1); + continue; + } + } else { + if (!getBlock(this.world!.getBlock(d.supportX, d.supportY, d.supportZ)).solid) { + d.grounded = false; + d.vy = 0; + continue; + } + d.mesh.position.y = d.baseY + Math.sin(d.age * 4) * DROP_FLOAT_AMPLITUDE; + } + d.mesh.rotation.y += dt * 1.8; + const dx = d.mesh.position.x - p.x; + const dy = d.mesh.position.y - (p.y + PLAYER_HEIGHT * 0.45); + const dz = d.mesh.position.z - p.z; + if (dx * dx + dy * dy + dz * dz > DROP_PICKUP_RADIUS * DROP_PICKUP_RADIUS) continue; + const before = d.count; + const leftover = this.inventory.add(d.id, d.count); + if (leftover <= 0) { + d.mesh.dispose(); + this.drops.splice(i, 1); + this.refreshHud(); + } else if (leftover < before) { + d.count = leftover; + this.refreshHud(); + } else { + const now = performance.now(); + if (now - this.lastDropFullToastAt > 1200) { + this.lastDropFullToastAt = now; + this.hud.showToast("Inventory full"); + } + } + } + } + + private clearDrops(): void { + for (const d of this.drops) d.mesh.dispose(); + this.drops.length = 0; + } + private placeBlock(t: { px: number; py: number; pz: number }): void { const sel = this.inventory.getSlot(this.selectedIndex); dbg("placeBlock", JSON.stringify({ sel: sel ? sel.id : null, px: t.px, py: t.py, pz: t.pz })); @@ -903,8 +1059,22 @@ export class Game { private saveState(): void { if (!this.world) return; + if (this.settings.mode === "survival") { + writeSave(this.settings.seed, { + inventory: this.inventory.serialize(), + crafting: this.inventory.serializeCrafting(), + stats: this.stats.serialize(), + mode: this.settings.mode, + }); + return; + } + // Creative: never persist inventory/craft-grid changes (palette pulls are + // ephemeral). Preserve the last survival backpack so returning to survival + // still restores progress; only the resumed mode + vitals are updated. + const prev = loadSave(this.settings.seed); writeSave(this.settings.seed, { - inventory: this.inventory.serialize(), + inventory: prev?.inventory ?? [], + crafting: prev?.crafting ?? [], stats: this.stats.serialize(), mode: this.settings.mode, }); @@ -1085,6 +1255,9 @@ export class Game { dispose(): void { this.saveState(); + this.clearDrops(); + for (const mat of this.dropMaterials.values()) mat.dispose(); + this.dropMaterials.clear(); this.renderer.engine.stopRenderLoop(); this.running = false; window.removeEventListener("resize", this.handleResize); @@ -1125,7 +1298,7 @@ export class Game { /** Clear the saved survival progress for the current seed (debug helper). */ _resetProgress(): void { clearSave(this.settings.seed); - this.inventory.clear(); + this.inventory.clear(); // also clears the crafting grid this.stats.reset(); this.refreshHud(); } diff --git a/src/game/Inventory.ts b/src/game/Inventory.ts index 3f07f2c..39ad43c 100644 --- a/src/game/Inventory.ts +++ b/src/game/Inventory.ts @@ -15,12 +15,20 @@ function stackMax(id: ItemId): number { * the main grid in the inventory screen). Supports add-with-merge, cursor-based * pickup/place/swap, and JSON serialization. */ +/** Number of cells in the logical crafting grid (3x3). The inventory screen + * exposes only the top-left 2x2 (indices 0,1,3,4); a crafting table exposes + * the full 3x3. match() handles either via bounding-box normalization. */ +export const CRAFT_GRID_SIZE = 9; + export class Inventory { readonly slots: (ItemStack | null)[]; + /** Crafting grid (always 9 cells, 3x3 logical). Separate from `slots`. */ + readonly craftingGrid: (ItemStack | null)[]; readonly hotbarSize: number; constructor(size: number, hotbarSize = 9) { this.slots = new Array(size).fill(null); + this.craftingGrid = new Array(CRAFT_GRID_SIZE).fill(null); this.hotbarSize = hotbarSize; } @@ -38,6 +46,16 @@ export class Inventory { this.slots[i] = stack; } + getCraft(i: number): ItemStack | null { + return this.craftingGrid[i] ?? null; + } + + setCraft(i: number, stack: ItemStack | null): void { + if (i < 0 || i >= this.craftingGrid.length) return; + if (stack && stack.count <= 0) stack = null; + this.craftingGrid[i] = stack; + } + hotbarSlot(i: number): ItemStack | null { return this.slots[i] ?? null; } @@ -98,10 +116,15 @@ export class Inventory { clear(): void { for (let i = 0; i < this.slots.length; i++) this.slots[i] = null; + this.clearCrafting(); + } + + clearCrafting(): void { + for (let i = 0; i < this.craftingGrid.length; i++) this.craftingGrid[i] = null; } isEmpty(): boolean { - return this.slots.every((s) => !s); + return this.slots.every((s) => !s) && this.craftingGrid.every((s) => !s); } serialize(): SerializedSlot[] { @@ -123,6 +146,26 @@ export class Inventory { } } } + + serializeCrafting(): SerializedSlot[] { + const out: SerializedSlot[] = []; + for (let i = 0; i < this.craftingGrid.length; i++) { + const s = this.craftingGrid[i]; + if (s) out.push({ i, id: s.id, count: s.count }); + } + return out; + } + + loadCrafting(data: SerializedSlot[] | undefined): void { + this.clearCrafting(); + if (!Array.isArray(data)) return; + for (const e of data) { + if (e.i >= 0 && e.i < this.craftingGrid.length && e.count > 0 && getItem(e.id)) { + const max = stackMax(e.id); + this.craftingGrid[e.i] = { id: e.id, count: Math.min(e.count, max) }; + } + } + } } export interface SerializedSlot { diff --git a/src/game/Items.ts b/src/game/Items.ts index 58ebe63..b01decd 100644 --- a/src/game/Items.ts +++ b/src/game/Items.ts @@ -24,7 +24,7 @@ export interface ItemDef { color: string; maxStack: number; /** UI rendering hint. */ - icon: "block" | "food"; + icon: "block" | "food" | "material"; /** If set, using this item places the given block. */ block?: BlockId; /** If set, this item can be eaten. */ @@ -44,6 +44,15 @@ const FOOD_ITEMS: ItemDef[] = [ { id: "golden_apple", name: "Golden Apple", color: "#f2c94c", maxStack: 64, icon: "food", food: { hunger: 8, saturation: 9.6 } }, ]; +/** + * Standalone crafting materials (not placeable, not edible). Planks and sticks + * are the intermediates produced by the starter recipes in Recipes.ts. + */ +const MATERIAL_ITEMS: ItemDef[] = [ + { id: "planks", name: "Planks", color: "#b08654", maxStack: 64, icon: "material" }, + { id: "stick", name: "Stick", color: "#9a6a3a", maxStack: 64, icon: "material" }, +]; + /** Blocks whose placement form is edible. */ const EDIBLE_BLOCKS = new Set([MUSHROOM_BLOCK]); @@ -67,7 +76,7 @@ function buildBlockItems(): ItemDef[] { return items; } -export const ITEMS: readonly ItemDef[] = [...buildBlockItems(), ...FOOD_ITEMS]; +export const ITEMS: readonly ItemDef[] = [...buildBlockItems(), ...FOOD_ITEMS, ...MATERIAL_ITEMS]; const ITEM_INDEX = new Map(ITEMS.map((it) => [it.id, it])); @@ -89,6 +98,7 @@ export const CREATIVE_PALETTE: readonly ItemId[] = [ "b24", "b25", "b26", "b7", "apple", "bread", "cooked_beef", "cookie", "golden_apple", + "planks", "stick", "b38", ]; /** diff --git a/src/game/Recipes.ts b/src/game/Recipes.ts new file mode 100644 index 0000000..db94076 --- /dev/null +++ b/src/game/Recipes.ts @@ -0,0 +1,250 @@ +import type { ItemId } from "./Items"; + +// Crafting recipe registry + a pure grid matcher. Recipes come in two flavours: +// +// * shaped — a 2D pattern of single-char keys. Position matters, but the +// matcher is symmetry-tolerant: a recipe matches the grid's +// bounding box under ANY of the 8 dihedral transforms (rotations +// + reflections), so a vertical "stick" also matches when the +// player lays the ingredients out horizontally. +// * shapeless — a multiset of ingredients; order and position are irrelevant. +// +// `match()` takes a flat grid of item ids (length 4 for the 2x2 inventory grid, +// length 9 for a 3x3 crafting table) and returns the matched recipe plus the +// output stack, or null. It is intentionally pure (no Inventory / DOM access) +// so it can be unit-tested in isolation. + +/** A shaped recipe: a 2D pattern of single-char keys mapped to item ids. */ +export interface ShapedRecipe { + type: "shaped"; + /** + * Pattern rows. Each string is one row; each char is one cell. A space or "." + * denotes an empty cell. All rows must be the same length. + */ + pattern: string[]; + /** Maps each non-empty pattern char to the ingredient item id it represents. */ + key: Record; + /** Output item id. */ + result: ItemId; + /** Output stack size (default 1). */ + count?: number; +} + +/** A shapeless recipe: a multiset of ingredients, position/order irrelevant. */ +export interface ShapelessRecipe { + type: "shapeless"; + /** Ingredient item ids — one entry per item consumed. */ + ingredients: ItemId[]; + result: ItemId; + count?: number; +} + +export type Recipe = ShapedRecipe | ShapelessRecipe; + +/** The outcome of a successful match. */ +export interface MatchResult { + /** The recipe that matched (carries result + count + inputs). */ + recipe: Recipe; + /** Output item id. */ + result: ItemId; + /** Output stack size. */ + count: number; +} + +// Item ids referenced by the starter recipes. The "log" is the Wood block item +// (block id 5 → item "b5"); planks/stick are pure material items registered in +// Items.ts; the crafting table is block id 38 → item "b38". +const LOG = "b5"; +const PLANKS = "planks"; +const STICK = "stick"; +const CRAFTING_TABLE = "b38"; + +/** + * The recipe registry. Append-only: existing entries keep their position so + * saves that might one day reference recipe ids stay stable. + */ +export const RECIPES: readonly Recipe[] = [ + // 1 log -> 4 planks (shapeless — position irrelevant). + { + type: "shapeless", + ingredients: [LOG], + result: PLANKS, + count: 4, + }, + // 2 planks (stacked) -> 4 sticks. Matches vertical OR horizontal placement + // thanks to the dihedral-symmetry matcher. + { + type: "shaped", + pattern: ["P", "P"], + key: { P: PLANKS }, + result: STICK, + count: 4, + }, + // 4 planks (2x2) -> 1 crafting table. + { + type: "shaped", + pattern: ["PP", "PP"], + key: { P: PLANKS }, + result: CRAFTING_TABLE, + count: 1, + }, +]; + +// --------------------------------------------------------------------------- +// Matcher internals +// --------------------------------------------------------------------------- + +type Cell = ItemId | null; +type Matrix = Cell[][]; + +function isFilled(v: ItemId | null | undefined): v is ItemId { + return v !== null && v !== undefined; +} + +function patternToMatrix(recipe: ShapedRecipe): Matrix { + const rows = recipe.pattern.length; + const cols = rows > 0 ? recipe.pattern[0].length : 0; + const m: Matrix = []; + for (let r = 0; r < rows; r++) { + const prow = recipe.pattern[r] ?? ""; + const row: Cell[] = []; + for (let c = 0; c < cols; c++) { + const ch = prow[c]; + row.push(ch === undefined || ch === " " || ch === "." ? null : (recipe.key[ch] ?? null)); + } + m.push(row); + } + return m; +} + +function colEmpty(m: Matrix, c: number): boolean { + for (let r = 0; r < m.length; r++) if (m[r][c] !== null) return false; + return true; +} + +/** Trim fully-empty border rows/columns, returning the smallest filled sub-matrix. */ +function trim(m: Matrix): Matrix { + let top = 0; + let bottom = m.length - 1; + while (top <= bottom && m[top].every((v) => v === null)) top++; + while (bottom >= top && m[bottom].every((v) => v === null)) bottom--; + if (top > bottom) return []; + const width = m[0].length; + let left = 0; + let right = width - 1; + while (left <= right && colEmpty(m, left)) left++; + while (right >= left && colEmpty(m, right)) right--; + if (left > right) return []; + const out: Matrix = []; + for (let r = top; r <= bottom; r++) out.push(m[r].slice(left, right + 1)); + return out; +} + +function matricesEqual(a: Matrix, b: Matrix): boolean { + if (a.length !== b.length) return false; + for (let r = 0; r < a.length; r++) { + const ar = a[r]; + const br = b[r]; + if (ar.length !== br.length) return false; + for (let c = 0; c < ar.length; c++) if (ar[c] !== br[c]) return false; + } + return true; +} + +/** Reflect a matrix left-to-right. */ +function reflect(m: Matrix): Matrix { + return m.map((row) => [...row].reverse()); +} + +/** Rotate a matrix 90° clockwise. */ +function rotate90(m: Matrix): Matrix { + const rows = m.length; + const cols = rows > 0 ? m[0].length : 0; + const out: Matrix = []; + for (let c = 0; c < cols; c++) { + const row: Cell[] = []; + for (let r = rows - 1; r >= 0; r--) row.push(m[r][c]); + out.push(row); + } + return out; +} + +/** + * Yield the unique matrices in the dihedral group D4 of `m` (the 8 rotations + + * reflections of a square). Used so a shaped recipe matches regardless of how + * the player oriented it on the grid. + */ +function symmetries(m: Matrix): Matrix[] { + const seen: Matrix[] = []; + const push = (x: Matrix): void => { + if (!seen.some((s) => matricesEqual(s, x))) seen.push(x); + }; + let cur = m; + for (let i = 0; i < 4; i++) { + push(cur); + cur = rotate90(cur); + } + cur = reflect(m); + for (let i = 0; i < 4; i++) { + push(cur); + cur = rotate90(cur); + } + return seen; +} + +function matchShaped(recipe: ShapedRecipe, gridTrimmed: Matrix): boolean { + const pat = trim(patternToMatrix(recipe)); + if (pat.length === 0) return false; + for (const variant of symmetries(pat)) { + if (matricesEqual(variant, gridTrimmed)) return true; + } + return false; +} + +function matchShapeless(recipe: ShapelessRecipe, items: ItemId[]): boolean { + if (items.length !== recipe.ingredients.length) return false; + const want = [...recipe.ingredients].sort(); + const have = [...items].sort(); + return want.every((x, i) => x === have[i]); +} + +// --------------------------------------------------------------------------- +// Public matcher +// --------------------------------------------------------------------------- + +/** + * Match a crafting grid against the recipe registry. + * + * @param grid flat array of slots. Length 4 → 2x2 (inventory grid); length 9 → + * 3x3 (crafting table). `null` denotes an empty cell. Any other length, or a + * non-square length, yields null. + * @returns the match (recipe + result + count), or null if nothing matches. + */ +export function match(grid: (ItemId | null)[]): MatchResult | null { + const w = Math.round(Math.sqrt(grid.length)); + if (w < 1 || w * w !== grid.length) return null; + + // Build the w×w matrix and its trimmed bounding box. + const matrix: Matrix = []; + for (let r = 0; r < w; r++) { + const row: Cell[] = []; + for (let c = 0; c < w; c++) row.push(grid[r * w + c] ?? null); + matrix.push(row); + } + const trimmed = trim(matrix); + if (trimmed.length === 0) return null; + + // Flatten non-null items for shapeless comparison. + const items = grid.filter(isFilled); + + for (const recipe of RECIPES) { + if (recipe.type === "shapeless") { + if (matchShapeless(recipe, items)) { + return { recipe, result: recipe.result, count: recipe.count ?? 1 }; + } + } else if (matchShaped(recipe, trimmed)) { + return { recipe, result: recipe.result, count: recipe.count ?? 1 }; + } + } + return null; +} diff --git a/src/game/gen/BlockIds.ts b/src/game/gen/BlockIds.ts index 2e43a98..a4aa40c 100644 --- a/src/game/gen/BlockIds.ts +++ b/src/game/gen/BlockIds.ts @@ -40,5 +40,7 @@ export const BIRCH_WOOD = 34; export const BIRCH_LEAVES = 35; export const SPRUCE_LEAVES = 36; export const SNOWY_LEAVES = 37; +// (38 Crafting Table — player-crafted via the Recipes registry, not placed by +// terrain gen. ID must stay stable: chunk data stores raw block ids.) export type BlockId = number; diff --git a/src/state/SaveData.ts b/src/state/SaveData.ts index 1dab6d1..284806f 100644 --- a/src/state/SaveData.ts +++ b/src/state/SaveData.ts @@ -7,10 +7,17 @@ import type { GameMode } from "../game/Items"; * localStorage key so a fresh world or a re-seed starts clean. */ -const VERSION = "v1"; +// Bumped to v2: creative-mode inventory is no longer persisted (palette pulls +// are ephemeral), so older v1 saves — which could contain a full creative +// backpack — are intentionally ignored. Survival progress from v1 is reset too; +// re-playing in survival rebuilds a clean save under the new semantics. +const VERSION = "v2"; export interface SaveData { inventory: SerializedSlot[]; + /** Optional crafting-grid state (returned-to-backpack on close, but + * persisted so a full backpack doesn't strand items mid-session). */ + crafting?: SerializedSlot[]; stats: SerializedStats; /** Optional — omitted/invalid values are ignored on load. */ mode?: GameMode; diff --git a/src/ui/InventoryUI.ts b/src/ui/InventoryUI.ts index 24d90fe..a97bbdb 100644 --- a/src/ui/InventoryUI.ts +++ b/src/ui/InventoryUI.ts @@ -6,6 +6,7 @@ import { type GameMode, type ItemId, } from "../game/Items"; +import { match, type MatchResult } from "../game/Recipes"; function el(tag: string, cls?: string): HTMLElement { const e = document.createElement(tag); @@ -37,6 +38,9 @@ export class InventoryUI { private paletteGrid!: HTMLElement; private searchInput!: HTMLInputElement; private readonly slotEls: HTMLElement[] = []; + /** Craft-input slot elements, indexed by craftingGrid index (0..8). */ + private readonly craftEls: HTMLElement[] = []; + private craftOutEl!: HTMLElement; private searchTerm = ""; @@ -76,15 +80,22 @@ export class InventoryUI { const craft = el("div", "inv-craft"); const craftLabel = el("div", "inv-section-label"); - craftLabel.textContent = "Crafting — Tier 3"; + craftLabel.textContent = "Crafting"; const craftGrid = el("div", "inv-craft-grid"); - for (let i = 0; i < 4; i++) { - const s = el("div", "slot slot-disabled"); - craftGrid.append(s); - } + // The inventory exposes the top-left 2x2 of the logical 3x3 crafting grid + // (indices 0,1,3,4). match() handles the smaller footprint via bounding-box + // normalization, so only recipes that fit in 2x2 can resolve here. + for (const ci of [0, 1, 3, 4]) craftGrid.append(this.makeCraftSlot(ci)); const craftArrow = el("div", "inv-arrow"); craftArrow.textContent = "→"; - const craftOut = el("div", "slot slot-disabled"); + const craftOut = el("div", "slot craft-out"); + craftOut.title = "Craft — click to take the result"; + craftOut.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (e.button === 0) this.craft(); + }); + craftOut.addEventListener("contextmenu", (ev) => ev.preventDefault()); + this.craftOutEl = craftOut; craft.append(craftLabel, craftGrid, craftArrow, craftOut, this.makeTrash()); const backpack = el("div", "inv-grid"); @@ -164,15 +175,33 @@ export class InventoryUI { return s; } - private leftClickSlot(index: number): void { - const stack = this.inventory.getSlot(index); + private makeCraftSlot(ci: number): HTMLElement { + const s = el("div", "slot inv-slot craft-slot"); + s.dataset.craft = String(ci); + s.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (e.button === 0) this.leftClickCraft(ci); + else if (e.button === 2) this.rightClickCraft(ci); + }); + s.addEventListener("contextmenu", (ev) => ev.preventDefault()); + this.craftEls[ci] = s; + return s; + } + + /** + * Generic left-click pickup/place/merge/swap against a slot expressed as a + * getter/setter pair. Backed by either an inventory slot or a craft cell, so + * the craft grid reuses the exact same drag/merge semantics as the backpack. + */ + private leftClickAt(get: () => ItemStack | null, set: (s: ItemStack | null) => void): void { + const stack = get(); if (!this.held) { if (stack) { this.held = stack; - this.inventory.setSlot(index, null); + set(null); } } else if (!stack) { - this.inventory.setSlot(index, this.held); + set(this.held); this.held = null; } else if (stack.id === this.held.id) { const max = getItem(stack.id)?.maxStack ?? 64; @@ -182,24 +211,23 @@ export class InventoryUI { this.held.count -= take; if (this.held.count <= 0) this.held = null; } else { - this.inventory.setSlot(index, this.held); + set(this.held); this.held = stack; } - this.afterChange(); } - private rightClickSlot(index: number): void { - const stack = this.inventory.getSlot(index); + private rightClickAt(get: () => ItemStack | null, set: (s: ItemStack | null) => void): void { + const stack = get(); if (!this.held) { if (stack && stack.count > 0) { const half = Math.ceil(stack.count / 2); this.held = { id: stack.id, count: half }; stack.count -= half; - if (stack.count <= 0) this.inventory.setSlot(index, null); + if (stack.count <= 0) set(null); } } else { if (!stack) { - this.inventory.setSlot(index, { id: this.held.id, count: 1 }); + set({ id: this.held.id, count: 1 }); this.held.count -= 1; } else if (stack.id === this.held.id) { const max = getItem(stack.id)?.maxStack ?? 64; @@ -210,9 +238,73 @@ export class InventoryUI { } if (this.held.count <= 0) this.held = null; } + } + + private leftClickSlot(index: number): void { + this.leftClickAt( + () => this.inventory.getSlot(index), + (s) => this.inventory.setSlot(index, s), + ); + this.afterChange(); + } + + private rightClickSlot(index: number): void { + this.rightClickAt( + () => this.inventory.getSlot(index), + (s) => this.inventory.setSlot(index, s), + ); + this.afterChange(); + } + + private leftClickCraft(ci: number): void { + this.leftClickAt( + () => this.inventory.getCraft(ci), + (s) => this.inventory.setCraft(ci, s), + ); + this.afterChange(); + } + + private rightClickCraft(ci: number): void { + this.rightClickAt( + () => this.inventory.getCraft(ci), + (s) => this.inventory.setCraft(ci, s), + ); + this.afterChange(); + } + + /** + * Resolve the current craft grid against the recipe registry and, on a match, + * place the output into the held cursor (merging if compatible) while + * consuming one item from every filled craft cell. No-op if the result can't + * land in the held slot. + */ + private craft(): void { + const m = this.currentMatch(); + if (!m) return; + const max = getItem(m.result)?.maxStack ?? 64; + if (!this.held) { + this.held = { id: m.result, count: m.count }; + } else if (this.held.id === m.result && this.held.count + m.count <= max) { + this.held.count += m.count; + } else { + return; // cursor holds something incompatible — don't consume inputs. + } + for (let i = 0; i < this.inventory.craftingGrid.length; i++) { + const s = this.inventory.getCraft(i); + if (s) { + s.count -= 1; + if (s.count <= 0) this.inventory.setCraft(i, null); + } + } this.afterChange(); } + /** Build a flat (ItemId|null)[] view of the crafting grid for the matcher. */ + private currentMatch(): MatchResult | null { + const grid = this.inventory.craftingGrid.map((s) => (s ? s.id : null)); + return match(grid); + } + private giveFromPalette(id: ItemId, full: boolean): void { const max = getItem(id)?.maxStack ?? 64; this.held = { id, count: full ? max : 1 }; @@ -221,6 +313,7 @@ export class InventoryUI { private afterChange(): void { this.renderSlots(); + this.renderCraft(); this.renderHeld(); this.onRefresh?.(); } @@ -231,6 +324,28 @@ export class InventoryUI { } } + private renderCraft(): void { + for (const [i, node] of this.craftEls.entries()) { + if (node) this.paintSlot(node, this.inventory.getCraft(i)); + } + this.paintOutput(this.currentMatch()); + } + + private paintOutput(m: MatchResult | null): void { + this.craftOutEl.classList.toggle("filled", !!m); + this.craftOutEl.innerHTML = ""; + if (!m) return; + const def = getItem(m.result); + const sw = el("div", "swatch"); + sw.style.background = def?.color ?? "#888"; + this.craftOutEl.append(sw); + if (m.count > 1) { + const c = el("span", "count"); + c.textContent = String(m.count); + this.craftOutEl.append(c); + } + } + private paintSlot(node: HTMLElement, stack: ItemStack | null): void { node.classList.toggle("filled", !!stack); node.innerHTML = ""; @@ -296,12 +411,12 @@ export class InventoryUI { this.toggleBtn.textContent = mode === "creative" ? "Switch to Survival" : "Switch to Creative"; this.paletteWrap.style.display = mode === "creative" ? "flex" : "none"; this.renderSlots(); + this.renderCraft(); this.renderPalette(); this.renderHeld(); } open(): void { - this.held = null; this.searchTerm = ""; this.searchInput.value = ""; this.refresh(); @@ -312,12 +427,27 @@ export class InventoryUI { // Return anything on the cursor to the inventory. if (this.held) { const leftover = this.inventory.add(this.held.id, this.held.count); - this.held = null; + this.held = leftover > 0 ? { id: this.held.id, count: leftover } : null; if (leftover > 0) this.onRefresh?.(); } + // Return anything left in the crafting grid to the backpack so items are + // never stranded. If the backpack is full, leave the remainder in its cell + // (the grid is persisted) rather than destroying it. + for (let i = 0; i < this.inventory.craftingGrid.length; i++) { + const s = this.inventory.getCraft(i); + if (!s) continue; + const leftover = this.inventory.add(s.id, s.count); + if (leftover > 0) this.inventory.setCraft(i, { id: s.id, count: leftover }); + else this.inventory.setCraft(i, null); + } this.root.setAttribute("hidden", ""); } + clearHeld(): void { + this.held = null; + this.renderHeld(); + } + get isOpen(): boolean { return !this.root.hasAttribute("hidden"); } diff --git a/src/ui/ui.css b/src/ui/ui.css index 5357b2f..5da2f73 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -775,9 +775,31 @@ input[type="checkbox"] { background: rgba(45, 212, 191, 0.14); transform: translateY(-1px); } -.slot.slot-disabled { - opacity: 0.35; - cursor: default; +/* Craft input slots behave like inventory slots (drag/swap). */ +.craft-slot { + cursor: pointer; +} +.craft-slot:hover { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.08); +} +/* Live recipe output: highlighted only when a recipe resolves. */ +.craft-out { + cursor: pointer; + border-color: rgba(255, 255, 255, 0.12); +} +.craft-out:hover { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.06); +} +.craft-out.filled { + border-color: var(--accent-2); + background: rgba(45, 212, 191, 0.14); + box-shadow: 0 0 10px rgba(45, 212, 191, 0.28); +} +.craft-out.filled:hover { + background: rgba(45, 212, 191, 0.24); + transform: translateY(-1px); } .slot.slot-trash { font-size: 18px; diff --git a/tests/recipes.test.ts b/tests/recipes.test.ts new file mode 100644 index 0000000..15a112d --- /dev/null +++ b/tests/recipes.test.ts @@ -0,0 +1,112 @@ +// Crafting recipe matcher tests. Run: bun tests/recipes.test.ts +// Exercises the pure match() in src/game/Recipes.ts: the three starter recipes +// across 2x2 and 3x3 grids, orientation invariance, and negative cases. + +import { match, RECIPES } from "../src/game/Recipes"; + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { console.error(" FAIL:", msg); failures++; } + else console.log(" ok:", msg); +} + +// Grids are flat arrays; index = row * width + col. 2x2 = length 4, 3x3 = 9. +// null = empty cell. +function g2(a: string | null, b: string | null, c: string | null, d: string | null) { + return [a, b, c, d]; +} + +const LOG = "b5"; +const PLANKS = "planks"; +const STICK = "stick"; +const TABLE = "b38"; + +console.log("\n[Setup] Registry ships the three starter recipes."); +assert(RECIPES.length >= 3, `recipe registry has >=3 entries (got ${RECIPES.length})`); + +// --- 1 log -> 4 planks (shapeless) ---------------------------------------- + +console.log("\n[Test 1] Shapeless: 1 log anywhere in a 2x2 grid -> 4 planks."); +{ + // log in each of the four 2x2 positions must resolve (shapeless = position-free). + const positions = [g2(LOG, null, null, null), g2(null, LOG, null, null), g2(null, null, LOG, null), g2(null, null, null, LOG)]; + for (let i = 0; i < positions.length; i++) { + const m = match(positions[i]); + assert(m !== null && m.result === PLANKS && m.count === 4, `log at 2x2 position ${i} -> 4 planks`); + } +} + +console.log("\n[Test 2] Shapeless works on a 3x3 grid too, position-free."); +{ + const grid = new Array(9).fill(null); + grid[7] = LOG; + const m = match(grid); + assert(m !== null && m.result === PLANKS && m.count === 4, "log in 3x3 corner -> 4 planks"); +} + +// --- 2 planks -> 4 sticks (shaped, orientation-invariant) ----------------- + +console.log("\n[Test 3] Shaped: 2 planks stacked vertically -> 4 sticks."); +{ + const m = match(g2(PLANKS, null, PLANKS, null)); + assert(m !== null && m.result === STICK && m.count === 4, "vertical planks -> 4 sticks"); +} + +console.log("\n[Test 4] Shaped is orientation-invariant: horizontal planks also match."); +{ + const m = match(g2(PLANKS, PLANKS, null, null)); + assert(m !== null && m.result === STICK && m.count === 4, "horizontal planks -> 4 sticks"); +} + +console.log("\n[Test 5] Diagonal planks do NOT match the stick recipe."); +{ + const m = match(g2(PLANKS, null, null, PLANKS)); + assert(m === null, "diagonal planks -> no match"); +} + +// --- 4 planks -> crafting table (shaped 2x2) ------------------------------ + +console.log("\n[Test 6] Shaped 2x2: 4 planks fill the grid -> 1 crafting table."); +{ + const m = match(g2(PLANKS, PLANKS, PLANKS, PLANKS)); + assert(m !== null && m.result === TABLE && m.count === 1, "2x2 planks -> 1 crafting table"); +} + +console.log("\n[Test 7] 3 planks in a 2x2 -> no match (partial table recipe)."); +{ + const m = match(g2(PLANKS, PLANKS, PLANKS, null)); + assert(m === null, "3 planks -> no match"); +} + +// --- Negative / edge cases ------------------------------------------------- + +console.log("\n[Test 8] Empty grid -> no match."); +{ + assert(match(g2(null, null, null, null)) === null, "all-empty 2x2 -> null"); + assert(match(new Array(9).fill(null)) === null, "all-empty 3x3 -> null"); +} + +console.log("\n[Test 9] Unrecognised input -> no match."); +{ + const m = match(g2("apple", null, null, null)); + assert(m === null, "an apple alone -> no recipe"); +} + +console.log("\n[Test 10] Wrong count for shapeless -> no match."); +{ + // stick recipe is shaped 2 planks; a single plank matches nothing. + const m = match(g2(PLANKS, null, null, null)); + assert(m === null, "1 plank alone -> no match"); +} + +console.log("\n[Test 11] Non-square grid length -> null (defensive)."); +{ + assert(match([LOG, PLANKS, STICK]) === null, "length-3 grid -> null"); +} + +if (failures === 0) { + console.log("\nAll recipe tests passed.\n"); +} else { + console.error(`\n${failures} recipe test(s) FAILED.\n`); + process.exit(1); +}