Rules rev 3: Ward is a choice, Chaos honors the shield

WARD's text says "you may play at that time" — under rules revision 3
the choice is made by setting the trap: an armWard command toggles it
on your own turn (your secret; a private wardSet event and a rail
note), and the spring fires only while set, consuming the card and
the arming together. Async games keep their agency without an
interrupt window, exactly as the ambush system solved this before.

CHAOS gains its printed interactions: "FULL SHIELD removes a player
from participation" — a defender's shield sits them out instead of
stopping the spell, and after the counter chain every bystander gets
a shield window in seat order (chaosPending; queue head owes a
response in lobby summaries, the rail says whose hand hangs in the
balance) before the pile forms among the unshielded. "REFLECTIONS
have no effect" — both reflections are refused as counters. ABSORB
SPELL still eats the whole thing through the existing absorb path.

Both changes are frozen behind deckRev 3 so every stored game — and
the live one — replays byte-for-byte under its own rules. Five new
tests cover armed/unarmed wards, shielded defenders and bystanders,
the reflection refusal, and the legacy auto-ward.

Not deployed — a live game is in progress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 12:55:58 -04:00
co-authored by Claude Fable 5
parent 41c384274d
commit 7e3e00bf97
7 changed files with 259 additions and 26 deletions
+123 -21
View File
@@ -180,6 +180,8 @@ export interface CastStack {
kind: "spell" | "physical";
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
waitingOn: PlayerId;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
}
export interface CastParams {
@@ -202,9 +204,10 @@ export interface GameConfig {
* seat order. */
colors?: number[];
/**
* Deck revision. Absent = the original build, which stored games replay
* against forever. Revision 2 removes LIFESAVER from two-player decks
* ("Not applicable in a 2-player game." — the card face).
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS.
*/
deckRev?: number;
}
@@ -217,6 +220,10 @@ export interface GameState {
edgeOverrides: Record<string, EdgeState>;
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
wallDamage: Record<string, number>;
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
wardArmed: PlayerId[];
/** 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. */
doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */
@@ -522,6 +529,8 @@ export type GameEvent =
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
@@ -575,6 +584,7 @@ export type Command =
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string }
@@ -1902,22 +1912,22 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack",
baseDamage: () => 0,
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
// (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions
// are not modeled — chaos resolves for all living players at once.)
// "FULL SHIELD removes a player from participation": under rules rev 3
// every bystander gets a shield window before the pile forms; earlier
// revisions scramble at once so stored games replay unchanged.
onResolved: (ctx) => {
const players = ctx.state.players.filter((p) => p.alive);
const counts = players.map((p) => p.hand.length);
const pile = players.flatMap((p) => p.hand.splice(0));
for (const p of players) p.displayed = [];
const [shuffled, rngNext] = shuffle(ctx.state.rng, pile);
ctx.state.rng = rngNext;
let i = 0;
players.forEach((p, pi) => {
p.hand = shuffled.slice(i, i + counts[pi]!);
i += counts[pi]!;
ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
});
ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id });
if (ctx.fullyStopped) return;
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
if ((ctx.state.config.deckRev ?? 1) >= 3) {
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
const queue = order.filter((id) =>
id !== ctx.attacker.id && id !== ctx.defender.id &&
ctx.state.players.find((p) => p.id === id)!.alive);
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
finishChaosIfReady(ctx.state, ctx.events);
return;
}
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
},
},
"illusionary-attack": {
@@ -2953,6 +2963,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
board,
edgeOverrides: {},
wallDamage: {},
wardArmed: [],
chaosPending: null,
doorStates: {},
openDoorEdges: [],
createdEdges: {},
@@ -3030,6 +3042,33 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
return err("an attack is being resolved — counteract or pass");
}
if (state.chaosPending) {
const head = state.chaosPending.queue[0];
if (playerId !== head) return err("waiting for another player to face the chaos");
if (command.type === "counteract") {
const st = clone(state);
const p = st.players.find((q) => q.id === playerId)!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
if (!card) return err("card not in hand");
if (card.cardId !== "full-shield") return err("only FULL SHIELD keeps your hand out of the chaos");
takeFromHand(p, command.instanceId);
st.discard.push(card);
st.chaosPending!.excluded.push(playerId);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [{ type: "chaosShielded", player: playerId }];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
if (command.type === "pass") {
const st = clone(state);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
return err("chaos is coming — shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
if (state.outOfTurnWindow) {
if (playerId !== state.outOfTurnWindow.playerId) {
@@ -3106,6 +3145,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side);
case "armWard": return doArmWard(state, command.armed);
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
@@ -3565,6 +3605,52 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
function doArmWard(prev: GameState, armed: boolean): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
const already = state.wardArmed.includes(p.id);
if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set");
state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id);
return {
ok: true,
state,
events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }],
};
}
/** Player ids in seat order, starting after `fromId`. */
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
const ids = state.players.map((p) => p.id);
const at = ids.indexOf(fromId);
return [...ids.slice(at + 1), ...ids.slice(0, at + 1)];
}
/** "Everyone tosses them in a pile" — except those FULL SHIELD sat out. */
function scrambleHands(state: GameState, events: GameEvent[], casterId: PlayerId, excluded: PlayerId[]): void {
const players = state.players.filter((p) => p.alive && !excluded.includes(p.id));
const counts = players.map((p) => p.hand.length);
const pile = players.flatMap((p) => p.hand.splice(0));
for (const p of players) p.displayed = [];
const [shuffled, rngNext] = shuffle(state.rng, pile);
state.rng = rngNext;
let i = 0;
players.forEach((p, pi) => {
p.hand = shuffled.slice(i, i + counts[pi]!);
i += counts[pi]!;
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
});
events.push({ type: "handsScrambled", caster: casterId });
}
function finishChaosIfReady(state: GameState, events: GameEvent[]): void {
const pending = state.chaosPending;
if (!pending || pending.queue.length > 0) return;
state.chaosPending = null;
scrambleHands(state, events, pending.casterId, pending.excluded);
}
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
const pre = attackPreconditions(prev);
if (pre) return err(pre);
@@ -4239,6 +4325,12 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
const castBlock = castingBlocked(state, playerId);
if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 &&
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
return err("REFLECTIONS have no effect against CHAOS");
}
if (playerId === stack.defenderId) {
if (card.cardId === "absorb-spell") {
if (stack.kind !== "spell") return err("absorb spell only works against spells");
@@ -4415,6 +4507,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
reversed: false,
kind: stack.kind,
};
if (attackId === "chaos" && (state.config.deckRev ?? 1) >= 3) {
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
if (shielded) {
stack.defenderShielded = true;
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
}
}
for (const counter of stack.counters) {
if (counter.nullified) continue;
if (isNumberCard(counter.card.cardId)) {
@@ -4637,15 +4736,18 @@ function doPickUpTreasure(prev: GameState): CommandResult {
const events: GameEvent[] = [
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
];
// WARD: the treasure's owner may have trapped it. (Simplification: springs
// automatically whenever the owner holds the card.)
// 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.
const owner = state.players.find((q) => q.id === t.owner);
if (owner && owner.alive && owner.id !== p.id) {
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 [card] = owner.hand.splice(wardIdx, 1);
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
state.wardArmed = state.wardArmed.filter((id) => id !== owner.id);
events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
applyDamage(state, events, p, 3, "warded treasure", null);
checkVictory(state, events);