From eaa672236b9f904326026647f44dbe286ada2fa0 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 00:21:02 -0400 Subject: [PATCH] The boobytrap keeps its secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public boobytrapPlaced event and every subsequent view shipped the four token cells in casting order — with the real trap always first, a tell readable by any client. Tokens are now stored and broadcast in canonical cell order while the truth lives only in realKey (and the caster's private event). Deterministic sort, no rng touched, replays unaffected; a test casts in reverse order and checks that position whispers nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 9 ++++-- .../engine/test/expansion-terrain.test.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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); + } + }); +});