The Ward is played in the moment (rules rev 31)

'When a player picks up one of your treasures, you may play at that
time (out of turn) this card on him' — the owner read the card and is
right: no arming ahead. When a treasure whose owner holds WARD is
grabbed, the grab hangs (new wardPending phase, outranking all other
input) and the owner alone answers: spring it (3 damage, card spent)
or let them go (card kept, silence). Automatons always spring it on a
thief of their gold. The arming mechanic is refused at rev 31 and its
button hidden; rev 3-30 games keep their armed wards and rev 1-2 keep
the automatic spring, replaying unchanged. The window does reveal that
the owner holds SOMETHING - as it would at any real table when the
room turns to look at them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-19 20:17:43 -04:00
co-authored by Claude Fable 5
parent b7f93fe454
commit 75d3d44c6c
8 changed files with 137 additions and 12 deletions
+52 -7
View File
@@ -257,6 +257,9 @@ export interface GameState {
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
wardArmed: PlayerId[];
/** A treasure was just grabbed and its owner holds WARD (rules rev 31):
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
/** Permanent door-lock changes, by edge key. */
@@ -590,6 +593,7 @@ export type GameEvent =
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
| { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
@@ -654,6 +658,7 @@ export type Command =
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean }
| { type: "wardChoice"; play: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string }
@@ -3447,6 +3452,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
wallDamage: {},
slimeTraps: {},
wardArmed: [],
wardPending: null,
chaosPending: null,
doorStates: {},
openDoorEdges: [],
@@ -3519,6 +3525,13 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
function applyCommandInner(state: GameState, playerId: PlayerId, command: Command): CommandResult {
if (state.phase !== "playing") return err("game is over");
// WARD's moment: the grab hangs while the treasure's owner decides.
if (state.wardPending) {
if (playerId !== state.wardPending.ownerId) return err("waiting on the treasure's owner");
if (command.type !== "wardChoice") return err("play your Ward or let them go");
return doWardChoice(state, command.play);
}
if (state.pendingDiscard) {
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
if (command.type !== "discard") return err("you must discard down to the hand limit first");
@@ -3637,6 +3650,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "punchWall": return doPunchWall(state, command.cell, command.side);
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
case "armWard": return doArmWard(state, command.armed);
case "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
@@ -4197,7 +4211,33 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
function doWardChoice(prev: GameState, play: boolean): CommandResult {
const state = clone(prev);
const wp = state.wardPending!;
state.wardPending = null;
const events: GameEvent[] = [];
if (play) {
const owner = state.players.find((q) => q.id === wp.ownerId)!;
const taker = state.players.find((q) => q.id === wp.takerId)!;
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
if (wardIdx === -1) return err("the Ward is no longer in your hand");
const [card] = owner.hand.splice(wardIdx, 1);
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
applyDamage(state, events, taker, 3, "warded treasure", null);
checkVictory(state, events);
}
return { ok: true, state, events };
}
function doArmWard(prev: GameState, armed: boolean): CommandResult {
// Rev 31 reads the card literally: the Ward is played in the moment of
// the grab, never set ahead. Older games keep their arming and replay so.
if ((prev.config.deckRev ?? 1) >= 31) {
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
}
const state = clone(prev);
const p = activePlayer(state);
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
@@ -5702,14 +5742,19 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const events: GameEvent[] = [
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
];
// WARD: "you may play at that time (out of turn) this card on him" — the
// choice is made ahead of time by arming it (rev 3); earlier revisions
// spring automatically so stored games replay unchanged.
// WARD: "you may play at that time (out of turn) this card on him."
// From rules rev 31 that is literal: the grab hangs while the owner
// decides. Rev 3-30 committed the choice ahead of time by arming; rev
// 1-2 sprang automatically. Stored games replay their own vintage.
const owner = state.players.find((q) => q.id === t.owner);
const wardSet = (state.config.deckRev ?? 1) >= 3 ? state.wardArmed.includes(owner?.id ?? "") : true;
if (owner && owner.alive && owner.id !== p.id && wardSet) {
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
if (wardIdx !== -1) {
const rev = state.config.deckRev ?? 1;
if (owner && owner.alive && owner.id !== p.id) {
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
if (rev >= 31 && holdsWard) {
state.wardPending = { ownerId: owner.id, takerId: p.id };
events.push({ type: "wardWindow", owner: owner.id, victim: p.id });
} else if (holdsWard && (rev >= 3 ? state.wardArmed.includes(owner.id) : true)) {
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
const [card] = owner.hand.splice(wardIdx, 1);
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);