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
+8 -2
View File
@@ -956,8 +956,9 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
// Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
// The ward guards the gold while its owner is away robbing yours.
if (inHand(view, "ward") && !view.yourWardArmed) {
// The ward guards the gold while its owner is away robbing yours
// (arming retired at rev 31: the window asks in the moment instead).
if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) {
return { type: "armWard", armed: true };
}
const enemyNear = livingEnemies(view).some(
@@ -1086,6 +1087,10 @@ export function automatonCommand(
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
if (view.wardPending) {
// A Ward is free damage on a thief of OUR gold: always spring it.
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
}
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
}
@@ -1460,6 +1465,7 @@ export function automatonCommand(
/** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you;
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
+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!);
+3
View File
@@ -84,6 +84,8 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** Whether YOUR ward is set to spring. */
yourWardArmed: boolean;
/** A grab hangs while the treasure's owner decides their Ward (rev 31). */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */
@@ -168,6 +170,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
yourWardArmed: state.wardArmed.includes(playerId),
wardPending: state.wardPending ? { ...state.wardPending } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })),
+50
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { giveCard } from "./helpers";
import {
applyCommand,
activePlayer,
@@ -262,3 +263,52 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)",
expect(btAfter.position).toEqual(cAfter.position);
});
});
describe("the Ward is played in the moment (rules rev 31)", () => {
function grabRig() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
const thief = state.players.find((p) => p.id === "thief")!;
const owner = state.players.find((p) => p.id === "owner")!;
giveCard(state, "owner", "ward", "W", 0);
const t = state.treasures.find((t) => t.owner === "owner" && t.position)!;
thief.position = { ...t.position! };
while (state.players[state.turn.activeIndex]!.id !== "thief") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "thief", { type: "pickUpTreasure" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, owner, thief };
}
it("the grab hangs on the owner; springing costs the thief 3 and the card", () => {
let { state } = grabRig();
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
// Nobody else may act while it hangs.
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.wardPending).toBeNull();
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("declining lets the thief go, Ward still in hand", () => {
let { state } = grabRig();
const r = applyCommand(state, "owner", { type: "wardChoice", play: false });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "thief")!.life).toBe(15);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true);
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
});
it("arming is refused in this vintage", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
expect(r.ok).toBe(false);
});
});
+2 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 30;
const RULES_REV = 31;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -275,6 +275,7 @@ function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.wardPending?.ownerId ??
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
+20 -1
View File
@@ -1206,6 +1206,25 @@
</div>
{/if}
{#if view?.wardPending}
{#if view.wardPending.ownerId === view.you}
<div class="scrim attack-scrim" role="alertdialog" aria-label="your treasure is taken">
<div class="attack-notice">
<div class="attack-title">{view.wardPending.takerId} grabs your treasure!</div>
<div class="attack-power">
"When a player picks up one of your treasures, you may play at
that time (out of turn) this card on him." Three points, and the
Ward is spent.
</div>
<div class="attack-actions">
<button class="stamp primary" onclick={() => dispatch({ type: "wardChoice", play: true })}>Spring the Ward!</button>
<button class="hint-cancel" onclick={() => dispatch({ type: "wardChoice", play: false })}>let them go</button>
</div>
</div>
</div>
{/if}
{/if}
{#if prefsOpen}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
@@ -2043,7 +2062,7 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button>
{/each}
{#if holdingWard}
{#if holdingWard && view.deckRev < 31}
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
{/if}
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 30,
deckRev: 31,
};
const { state, events } = createGame(config);
for (const e of events) {
+1
View File
@@ -61,6 +61,7 @@ export function humanize(e: GameEvent): string | null {
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "trapSprung": return e.cardId === "gift-from-below"
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
: `${e.player} walked into an old TRAP! Lose a turn.`;