From 5f1eda273127dcb8a1c7a894481a7c47b13820b3 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 22:52:09 -0400 Subject: [PATCH] Boobytrap decoys die their own deaths Stepping on a face-down token flips it: a blank leaves the board the moment it is discovered, instead of all four indicators lingering until the real one detonates. The trap itself still waits, the caster still strolls over everything unharmed, and the chronicle narrates the flip so the table learns which squares stopped being scary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 11 ++++- .../engine/test/expansion-terrain.test.ts | 46 +++++++++++++++++++ packages/web/src/net.svelte.ts | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 56f6a3b..ec06d0b 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -662,6 +662,7 @@ export type GameEvent = | { type: "stuckInSlime"; player: PlayerId; at: Cell } | { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] } | { type: "boobytrapSprung"; player: PlayerId; at: Cell } + | { type: "boobytrapBlank"; player: PlayerId; at: Cell } | { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell } | { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number } | { type: "safeCreated"; caster: PlayerId; at: Cell } @@ -4287,14 +4288,20 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { // Armed ambushes may spring on this step. checkAmbushes(state, events, p, { movedFrom: from }); - // BOOBYTRAP: the real token detonates under anyone but its caster. + // BOOBYTRAP: the real token detonates under anyone but its caster; a + // blank token is flipped by the step that finds it and leaves the board + // alone — each decoy dies its own death. for (const trap of [...state.boobytraps]) { if (trap.casterId === p.id) continue; - if (cellKey(p.position) === trap.realKey) { + const here = cellKey(p.position); + if (here === trap.realKey) { state.boobytraps = state.boobytraps.filter((t) => t !== trap); events.push({ type: "boobytrapSprung", player: p.id, at: p.position }); applyDamage(state, events, p, 4, "boobytrap", null, "physical"); checkVictory(state, events); + } else if (trap.cells.some((c) => cellKey(c) === here)) { + trap.cells = trap.cells.filter((c) => cellKey(c) !== here); + events.push({ type: "boobytrapBlank", player: p.id, at: p.position }); } } diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index a6bb770..28b16de 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -378,3 +378,49 @@ describe("a wave's force is spent as it travels", () => { }); }); + +describe("boobytrap decoys die their own deaths", () => { + it("a stepped-on blank token vanishes alone; the real one still waits", () => { + let { state } = newGame(); + const me = activePlayer(state); + const enemy = state.players.find((p) => p.id !== me.id)!; + const view = boardView(state); + const open: Cell[] = []; + for (const key of Object.keys(view.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + if (state.squareContents[key]) continue; + open.push({ x, y }); + if (open.length === 4) break; + } + const bt = giveCard(state, me.id, "boobytrap"); + state = must(state, me.id, { + type: "cast", instanceId: bt.instanceId, params: { cells: open }, + }); + state = must(state, me.id, { type: "endTurn", draw: 0 }); + // The enemy steps onto a BLANK (open[1] — the real trap is open[0]). + const e = state.players.find((p) => p.id === enemy.id)!; + const blank = open[1]!; + for (const side of SIDES) { + const from = { x: blank.x + (side === "E" ? -1 : side === "W" ? 1 : 0), + y: blank.y + (side === "S" ? -1 : side === "N" ? 1 : 0) }; + if (!view.cells[cellKey(from)]) continue; + const t = stepTarget(view, from, side); + if (t.kind === "step" && cellKey(t.to) === cellKey(blank)) { + e.position = from; + const before = e.life; + const r = applyCommand(state, enemy.id, { type: "move", direction: side }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(r.events.some((ev) => ev.type === "boobytrapBlank")).toBe(true); + const after = state.players.find((p) => p.id === enemy.id)!; + expect(after.life).toBe(before); + const trap = state.boobytraps[0]!; + expect(trap.cells.length).toBe(3); + expect(trap.cells.some((c) => cellKey(c) === cellKey(blank))).toBe(false); + expect(trap.realKey).toBe(cellKey(open[0]!)); + return; + } + } + throw new Error("setup: no approach to the blank token"); + }); +}); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 141ce00..ebc1738 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -157,6 +157,7 @@ export function humanize(e: GameEvent): string | null { case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`; case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`; case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`; + 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;