diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ea581bb..b21b32c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2013,8 +2013,13 @@ const CARD_EFFECTS: Record } const uniq = new Set(cells.map(cellKey)); if (uniq.size !== 4) return "the four tokens go on four different squares"; - state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) }); - events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] }); + // The FIRST cell in the command is the real trap — but that truth + // lives only in realKey. Stored and broadcast in canonical order, + // the tokens carry no tell: position must never whisper which one + // bites (the placement order once did, to anyone reading events). + const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b))); + state.boobytraps.push({ casterId: caster.id, cells: laid, realKey: cellKey(cells[0]!) }); + events.push({ type: "boobytrapPlaced", caster: caster.id, cells: laid.map((c) => ({ ...c })) }); events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! }); return null; }, diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 28b16de..be91145 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -424,3 +424,34 @@ describe("boobytrap decoys die their own deaths", () => { throw new Error("setup: no approach to the blank token"); }); }); + +describe("the boobytrap keeps its secret", () => { + it("stored and broadcast cells are canonically ordered — position tells nothing", () => { + let { state } = newGame(); + const me = activePlayer(state); + 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; + } + // Cast with the REAL trap deliberately last-sorting: reverse order. + const reversed = [...open].reverse(); + const bt = giveCard(state, me.id, "boobytrap"); + const r = applyCommand(state, me.id, { + type: "cast", instanceId: bt.instanceId, params: { cells: reversed }, + }); + if (!r.ok) throw new Error(r.error); + const trap = r.state.boobytraps[0]!; + const keys = trap.cells.map((c) => cellKey(c)); + expect([...keys].sort()).toEqual(keys); // canonical order, not casting order + expect(trap.realKey).toBe(cellKey(reversed[0]!)); // the truth survives aside + const placed = r.events.find((e) => e.type === "boobytrapPlaced"); + if (placed?.type === "boobytrapPlaced") { + const evKeys = placed.cells.map((c) => cellKey(c)); + expect([...evKeys].sort()).toEqual(evKeys); + } + }); +});