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;