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);
+8
View File
@@ -67,6 +67,10 @@ export interface GameView {
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** CHAOS shield windows in progress (public: everyone sees it coming). */
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** Whether YOUR ward is set to spring. */
yourWardArmed: boolean;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */
@@ -132,6 +136,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
wandCharges: { ...state.wandCharges },
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
chaosPending: state.chaosPending
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
yourWardArmed: state.wardArmed.includes(playerId),
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })),
+100
View File
@@ -387,3 +387,103 @@ describe("attacking walls and doors", () => {
expect(r.ok).toBe(false);
});
});
describe("rules revision 3", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
}
it("ward springs only when its owner armed it", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
// Unarmed: the grab goes unpunished.
thief.position = { ...treasure.position! };
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
return s;
})();
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(s3.wardArmed).not.toContain(owner.id);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-reflection", "R", 0);
giveCard(state, bystander.id, "full-shield", "S", 0);
const bystanderHand = bystander.hand.map((c) => c.instanceId).sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
// "REFLECTIONS have no effect."
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false);
state = must(state, defender.id, { type: "pass" });
// Now the bystander's window: they shield out and keep their hand.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" });
expect(state.chaosPending).toBeNull();
const after = state.players.find((p) => p.id === bystander.id)!;
expect(after.hand.map((c) => c.instanceId).sort()).toEqual(
bystanderHand.filter((id) => id !== "full-shield#S").sort());
});
it("chaos: the defender's full shield sits them out without stopping it", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-shield", "S", 0);
const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort();
const kept = defenderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
// Bystander declines; the scramble happens without the defender.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "pass" });
expect(state.chaosPending).toBeNull();
expect(defenderHand()).toEqual(kept);
});
it("legacy games (rev < 3) keep the automatic ward", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
});
});
+7 -3
View File
@@ -43,6 +43,9 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 3;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -176,9 +179,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, 2);
const result = startInMemory(room, expansion, colors, RULES_REV);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: 2 });
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
recordRoom(room);
return result;
}
@@ -224,12 +227,13 @@ export interface GameSummary {
export function summarize(room: Room, playerId: PlayerId): GameSummary {
const s = room.state;
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active;
let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
if (s?.phase === "playing" && turnHolder === playerId) {
attention =
s.stack?.waitingOn === playerId ? "counteract"
: s.chaosPending?.queue[0] === playerId ? "counteract"
: s.pendingDiscard === playerId ? "discard"
: s.outOfTurnWindow?.playerId === playerId ? "interrupt"
: "turn";
+18 -1
View File
@@ -87,6 +87,8 @@
: null,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const EDGE_CARDS = new Set([
@@ -181,7 +183,7 @@
if (!view) return;
peekCard = null;
peekCreatureId = null;
if (youMustRespond) {
if (youMustRespond || youMustShield) {
dispatch({ type: "counteract", instanceId: card.instanceId });
return;
}
@@ -1003,6 +1005,17 @@
<div class="slip">{view.activePlayerId} is taking their turn…</div>
{/if}
{#if view.yourWardArmed}
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
{/if}
{#if youMustShield}
<div class="slip urgent">
Chaos comes for your hand — tap your Full Shield to sit out, or
<button class="stamp tiny" onclick={pass}>let it take you</button>
</div>
{:else if view.chaosPending}
<div class="slip">Chaos gathers — waiting on {view.chaosPending.queue[0]}</div>
{/if}
{#each idleCreatures as c (c.id)}
{@const moves = c.movesPerTurn - c.movementUsed}
<div class="slip creature-note">
@@ -1197,6 +1210,10 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button>
{/each}
{#if holdingWard}
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
{/if}
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
onclick={() => (punchWallMode = !punchWallMode)}>
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
+1 -1
View File
@@ -101,7 +101,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 2,
deckRev: 3,
};
const { state, events } = createGame(config);
this.config = config;
+2
View File
@@ -129,6 +129,8 @@ export function humanize(e: GameEvent): string | null {
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`;