The boobytrap keeps its secret

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 00:21:02 -04:00
co-authored by Claude Fable 5
parent 8ba0a934cc
commit eaa672236b
2 changed files with 38 additions and 2 deletions
+7 -2
View File
@@ -2013,8 +2013,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
} }
const uniq = new Set(cells.map(cellKey)); const uniq = new Set(cells.map(cellKey));
if (uniq.size !== 4) return "the four tokens go on four different squares"; 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]!) }); // The FIRST cell in the command is the real trap — but that truth
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] }); // 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]! }); events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
return null; return null;
}, },
@@ -424,3 +424,34 @@ describe("boobytrap decoys die their own deaths", () => {
throw new Error("setup: no approach to the blank token"); 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);
}
});
});