From eabc10f18c41fc897c13de16316039e1f5ab8906 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 09:22:56 -0400 Subject: [PATCH] =?UTF-8?q?Safes=20finally=20open=20to=20keys=20=E2=80=94?= =?UTF-8?q?=20and=20the=20clockwork=20stops=20clawing=20at=20the=20lid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'All LOCK-type cards will work on it' was never wired: openSafes was reset every turn and filled by nothing. A PICK LOCK or MASTER KEY aimed at a safe's square (underfoot or beside) now opens it until turn's end, with its own chronicle line; the dead never-emitted safeOpened variant and its null humanize go. The automaton — H4EN's Automaton II spent six turns grabbing at a locked lid saying 'I meant to do that' — now cracks the box first (key or DISPEL CREATION, which it held the whole time) and drops unopenable safes from its goals rather than marching to one forever. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 23 ++++++++++- packages/engine/src/game.ts | 13 +++++- packages/engine/test/automaton.test.ts | 36 ++++++++++++++++ .../test/terrain-objects-control.test.ts | 41 +++++++++++++++++++ packages/web/src/App.svelte | 7 ++++ packages/web/src/net.svelte.ts | 2 +- 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 0dce3fc..843fc67 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -652,6 +652,17 @@ function treasureGoals(view: GameView): Set { } for (const t of view.treasures) { if (!t.position || t.carriedBy) continue; + // A chest inside someone else's SAFE is no goal without a way in — a + // lock card turns the combination, DISPEL CREATION removes the box. + // Marching to an unopenable safe stalls the clockwork on it forever. + const boxKey = cellKey(t.position); + if (view.squareContents[boxKey]?.kind === "safe" && + view.squareContents[boxKey]!.createdBy !== view.you && + !view.openSafes.includes(boxKey) && + !inHand(view, "pick-lock") && !inHand(view, "master-key") && + !inHand(view, "dispel-creation")) { + continue; + } if (t.owner === view.you) { // REPOSSESSION: my own gold banked at an enemy's home is a point on // THEIR scoreboard. Marching to take it back is worth the detour @@ -1302,7 +1313,17 @@ export function automatonCommand( // Repossessing my own gold off an enemy's home square. view.players.some((p) => p.id !== you && cellKey(p.home) === here)), ); - if (prize) return { type: "pickUpTreasure", treasureId: prize.id }; + if (prize) { + // A SAFE over the prize: turn the combination (or dispel the box) + // before reaching for the gold — the grab itself would be refused. + const boxed = view.squareContents[here]?.kind === "safe" && + view.squareContents[here]!.createdBy !== you && !view.openSafes.includes(here); + if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id }; + const key = inHand(view, "pick-lock") ?? inHand(view, "master-key"); + if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } }; + const dispel = inHand(view, "dispel-creation"); + if (dispel) return { type: "cast", instanceId: dispel.instanceId, target: { kind: "cell", cell: self.position } }; + } } // Wounded clockwork teleports clear of visible hunters. diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index c2e25b0..e578e8c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -703,7 +703,6 @@ export type GameEvent = | { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell } | { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number } | { type: "safeCreated"; caster: PlayerId; at: Cell } - | { type: "safeOpened"; player: PlayerId; at: Cell } | { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell } | { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null; edge?: { cell: Cell; side: Side } } | { type: "handsSwapped"; a: PlayerId; b: PlayerId } @@ -745,6 +744,7 @@ export type GameEvent = | { type: "extraTurnGranted"; player: PlayerId } | { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number } | { type: "madDash"; player: PlayerId; newAllowance: number } + | { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string } | { type: "trapSprung"; player: PlayerId; cardId?: string } | { type: "died"; player: PlayerId; killedBy: PlayerId | null } | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } @@ -3588,6 +3588,17 @@ function unlockDoor( cardId: string, opts: { requireAdjacent: boolean }, ): string | null { + // "All LOCK-type cards will work on it" — aimed at a SAFE's square + // (underfoot or beside), the key opens it until the turn ends. + const cell = cmd.target?.kind === "cell" ? cmd.target.cell : null; + if (cell && state.squareContents[cellKey(cell)]?.kind === "safe") { + const d = Math.abs(caster.position.x - cell.x) + Math.abs(caster.position.y - cell.y); + if (d > 1) return "you must be at or beside the safe"; + const key = cellKey(cell); + if (!state.openSafes.includes(key)) state.openSafes.push(key); + events.push({ type: "safeOpened", player: caster.id, cell: { ...cell }, withCardId: cardId }); + return null; + } const found = doorTarget(state, cmd); if (typeof found === "string") return found; const key = edgeKey(found.cell, found.side); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 5f69a31..23a83f9 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1008,6 +1008,42 @@ describe("interception, escape, and hazard sense", () => { expect(applyCommand(state, "bot", cmd!).ok).toBe(true); }); + it("cracks a safe over the prize instead of grabbing at the lid forever", () => { + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + state = toBot(state); + const bot = state.players.find((p) => p.id === "bot")!; + const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!; + state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" }; + bot.position = { ...chest.position! }; + bot.hand = [{ cardId: "dispel-creation", instanceId: "DC" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } }); + const r = applyCommand(state, "bot", cmd!); + expect(r.ok).toBe(true); + // The box gone, the very next thought is the grab. + const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage"); + expect(next).toMatchObject({ type: "pickUpTreasure" }); + }); + + it("never proposes the doomed grab on a safe it cannot open", () => { + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + state = toBot(state); + const bot = state.players.find((p) => p.id === "bot")!; + const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!; + state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" }; + bot.position = { ...chest.position! }; + bot.hand = [{ cardId: "number-3", instanceId: "N3" }]; + for (let i = 0; i < 8; i++) { + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + if (!cmd) break; + expect(cmd.type).not.toBe("pickUpTreasure"); + const r = applyCommand(state, "bot", cmd); + expect(r.ok).toBe(true); + state = r.state; + if (cmd.type === "endTurn") break; + } + }); + it("a pressed carrier of any temperament turns to mist", () => { let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); state = toBot(state); diff --git a/packages/engine/test/terrain-objects-control.test.ts b/packages/engine/test/terrain-objects-control.test.ts index cb83ebb..0d9ee14 100644 --- a/packages/engine/test/terrain-objects-control.test.ts +++ b/packages/engine/test/terrain-objects-control.test.ts @@ -109,6 +109,47 @@ describe("terrain", () => { }); }); +describe("safes and lock cards", () => { + function safeRig() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); + state = toRound2(state); + const raider = activePlayer(state); + const owner = state.players.find((p) => p.id !== raider.id)!; + const chest = state.treasures.find((t) => t.owner === owner.id && t.position)!; + state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: owner.id }; + raider.position = { ...chest.position! }; + return { state, raider: raider.id, chest }; + } + + it("a pick lock aimed at the safe's square opens it until turn's end", () => { + let { state, raider, chest } = safeRig(); + expect(applyCommand(state, raider, { type: "pickUpTreasure" }).ok).toBe(false); + const pl = giveCard(state, raider, "pick-lock", "PL", 0); + const here = state.players.find((p) => p.id === raider)!.position; + state = must(state, raider, { + type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: { ...here } }, + }); + expect(state.openSafes).toContain(cellKey(here)); + state = must(state, raider, { type: "pickUpTreasure" }); + expect(state.treasures.find((t) => t.id === chest.id)!.carriedBy).toBe(raider); + state = must(state, raider, { type: "endTurn", draw: 0 }); + expect(state.openSafes).toEqual([]); + }); + + it("the key must be at or beside the safe", () => { + let { state, raider } = safeRig(); + const p = state.players.find((q) => q.id === raider)!; + const box = { ...p.position }; + p.position = { x: (box.x + 3) % 10, y: (box.y + 3) % 10 }; + const pl = giveCard(state, raider, "pick-lock", "PL", 0); + const r = applyCommand(state, raider, { + type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: box }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/beside the safe/); + }); +}); + describe("objects", () => { it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => { let { state } = newGame(); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 64f5337..1833a5f 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -786,6 +786,13 @@ confirmTeleport(); return; } + if (selectedCard && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") && + view.squareContents[cellKey(cell)]?.kind === "safe") { + // A key aimed at a SAFE's square opens the box, not a door. + dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); + clearSelection(); + return; + } if (selectedCard?.cardId === "relocate-sector") { // Any board click (re-)picks the sector; the landing is chosen from the // dashed ghost slots beyond the maze, since every on-board slot is taken. diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 113de87..98477a3 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -79,6 +79,7 @@ export function humanize(e: GameEvent): string | null { : `${e.player} is stone — the damage has no effect.`; case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`; case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`; + case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`; case "teleported": return e.by === e.player @@ -162,7 +163,6 @@ export function humanize(e: GameEvent): string | null { case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`; case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`; case "safeCreated": return `A massive safe slams down around the loot.`; - case "safeOpened": return null; case "itemsTraded": return `Two items blink and trade places.`; case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`; case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;