Walls and doors fall to sustained assault

"It is possible, though time-consuming, to punch a wall down. A wall
takes 20 points of damage to destroy; a door takes 15. Any attack
against an inanimate object counts as your one attack for the turn."
Damage accumulates per edge in wallDamage (remapped through sector
rotations, public in the view), fed two ways: a punchWall command for
the bare-fisted (1 point, from a square touching the edge) and attack
spells cast at an edge target — LOS to the wall for L.O.S. cards,
touching it for same-square cards, amplify and power-attack honored,
wand charges spent, no counteractions since stonework plays none.
Thrown daggers and rocks clatter to the floor at the foot of the
wall. At the threshold the edge opens through the same override path
destroy-wall uses.

On the table: damaged walls wear spreading cracks, an attack card's
hint offers "or a wall line to batter it" with the edge layer live,
and a "Punch a wall…" stamp arms a click-the-wall mode. The chronicle
counts the blows: "alice batters the wall with bare fists — 3/20."

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:48:49 -04:00
co-authored by Claude Fable 5
parent 64454ffc3e
commit 41c384274d
6 changed files with 220 additions and 4 deletions
+111
View File
@@ -215,6 +215,8 @@ export interface GameState {
board: AssembledBoard; board: AssembledBoard;
/** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */ /** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */
edgeOverrides: Record<string, EdgeState>; edgeOverrides: Record<string, EdgeState>;
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
wallDamage: Record<string, number>;
/** Permanent door-lock changes, by edge key. */ /** Permanent door-lock changes, by edge key. */
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */ /** Door edges unlocked until the end of the current turn. */
@@ -532,6 +534,7 @@ export type GameEvent =
| { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string } | { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string }
| { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger } | { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "wallDamaged"; player: PlayerId; edge: { cell: Cell; side: Side }; amount: number; total: number; needed: number; source: string }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number } | { type: "doorsRelocked"; count: number }
| { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } } | { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } }
@@ -571,6 +574,7 @@ export type Command =
| { type: "move"; direction: Side } | { type: "move"; direction: Side }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId } | { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
@@ -2654,6 +2658,7 @@ function remapState(
}; };
state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey); state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey);
state.wallDamage = remapRecord(state.wallDamage, mapEdgeKey);
state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey); state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey);
state.doorStates = remapRecord(state.doorStates, mapEdgeKey); state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
@@ -2947,6 +2952,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
phase: "playing", phase: "playing",
board, board,
edgeOverrides: {}, edgeOverrides: {},
wallDamage: {},
doorStates: {}, doorStates: {},
openDoorEdges: [], openDoorEdges: [],
createdEdges: {}, createdEdges: {},
@@ -3099,6 +3105,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "move": return doMove(state, command.direction); case "move": return doMove(state, command.direction);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId); case "punch": return doPunch(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side);
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
@@ -3519,6 +3526,60 @@ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target:
return null; return null;
} }
/**
* "A wall takes 20 points of damage to destroy; a door takes 15." Damage
* accumulates across turns and players; at the threshold the edge opens.
*/
function damageWall(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
cell: Cell,
side: Side,
amount: number,
source: string,
): string | null {
const view = boardView(state);
const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open";
if (current !== "wall" && current !== "door") {
return "only walls, doors, and thornbushes can be attacked";
}
const needed = current === "door" ? 15 : 20;
const total = (state.wallDamage[key] ?? 0) + amount;
events.push({ type: "wallDamaged", player: attacker.id, edge: { cell, side }, amount, total, needed, source });
if (total >= needed) {
delete state.wallDamage[key];
state.edgeOverrides[key] = "open";
delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "wallDestroyed", caster: attacker.id, edge: { cell, side }, wasDoor: current === "door" });
} else {
state.wallDamage[key] = total;
}
return null;
}
/** The edge must border the wizard's own square. */
function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
}
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
const pre = attackPreconditions(prev);
if (pre) return err(pre);
const state = clone(prev);
const attacker = activePlayer(state);
if (!touchesEdge(attacker.position, cell, side)) {
return err("you must stand beside the wall to punch it");
}
const events: GameEvent[] = [];
const problem = damageWall(state, events, attacker, cell, side, 1, "punch");
if (problem) return err(problem);
state.turn.attackUsed = true;
return { ok: true, state, events };
}
function doPunch(prev: GameState, targetId: PlayerId): CommandResult { function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev); const pre = attackPreconditions(prev);
if (pre) return err(pre); if (pre) return err(pre);
@@ -3813,6 +3874,56 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
return { ok: true, state, events }; return { ok: true, state, events };
} }
// "Any attack against an inanimate object counts as your one attack for
// the turn." A wall or door soaks the spell's damage; no counteractions.
if (cmd.target?.kind === "edge") {
const { cell, side } = cmd.target;
const view = boardView(state);
const current = view.edges[edgeKey(cell, side)] ?? "open";
if (current !== "wall" && current !== "door") {
return err("only walls, doors, and thornbushes can be attacked");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall");
}
if (effect.requiresLos && !losToEdge(view, caster.position, cell, side)) {
return err("no line of sight to the wall");
}
const dmg =
effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length) +
mods.powerAttackPoints;
if (dmg <= 0) return err("that spell cannot harm stonework");
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
if (mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell,
}];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem);
// Thrown weapons clatter to the floor at the foot of the wall.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) {
const [card] = state.discard.splice(di, 1);
state.groundObjects[cellKey(cell)] = [...(state.groundObjects[cellKey(cell)] ?? []), card!];
events2.push({ type: "objectThrown", attacker: caster.id, cardId: inHand.cardId, landedAt: cell });
}
}
return { ok: true, state, events: events2 };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player"); if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
+3
View File
@@ -55,6 +55,8 @@ export interface GameView {
squareContents: Record<string, SquareContent>; squareContents: Record<string, SquareContent>;
groundObjects: Record<string, CardInstance[]>; groundObjects: Record<string, CardInstance[]>;
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
openDoorEdges: string[]; openDoorEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */ /** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[]; knownIllusionEdges: string[];
@@ -123,6 +125,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
), ),
doorStates: { ...state.doorStates }, doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges], openDoorEdges: [...state.openDoorEdges],
knownIllusionEdges, knownIllusionEdges,
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
+60 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
@@ -328,3 +328,62 @@ describe("deck revisions", () => {
expect(inGame(legacy.state)).toBe(true); expect(inGame(legacy.state)).toBe(true);
}); });
}); });
describe("attacking walls and doors", () => {
function wallBeside(state: GameState): { cell: Cell; side: Side } {
const me = activePlayer(state);
const view = boardView(state);
for (const side of SIDES) {
if (view.edges[edgeKey(me.position, side)] === "wall") return { cell: me.position, side };
}
throw new Error("setup: seed 42 lost its adjacent wall");
}
it("punches chip a wall for 1 and spend the turn's attack", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
const { cell, side } = wallBeside(state);
state = must(state, me.id, { type: "punchWall", cell, side });
expect(state.wallDamage[edgeKey(cell, side)]).toBe(1);
expect(state.turn.attackUsed).toBe(true);
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
});
it("a powered fireball brings a wall down at 20 accumulated damage", () => {
let { state } = newGame();
state = toRound2(state);
let me = activePlayer(state);
const { cell, side } = wallBeside(state);
const key = edgeKey(cell, side);
// Fireball is a flat 5: four castings accumulate 5, 10, 15, then 20 fells it.
for (let round = 0; round < 4; round++) {
me = activePlayer(state);
const fb = giveCard(state, me.id, "fireball", `F${round}`, 0);
state = must(state, me.id, {
type: "cast", instanceId: fb.instanceId,
target: { kind: "edge", cell, side },
});
if (round < 3) {
expect(state.wallDamage[key]).toBe(5 * (round + 1));
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
}
expect(state.wallDamage[key]).toBeUndefined();
expect(boardView(state).edges[key]).toBe("open");
// The way is open: walk through where the wall stood.
state = must(state, me.id, { type: "move", direction: side });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(neighbor(cell, side)));
});
it("open corridors and firewalls are not punchable", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
const view = boardView(state);
const openSide = SIDES.find((s) => (view.edges[edgeKey(me.position, s)] ?? "open") === "open")!;
const r = applyCommand(state, me.id, { type: "punchWall", cell: me.position, side: openSide });
expect(r.ok).toBe(false);
});
});
+20 -3
View File
@@ -24,6 +24,8 @@
let peekCard = $state<CardInstance | null>(null); let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */ /** When the peeked card is a creature on the board, its live stats ride along. */
let peekCreatureId = $state<string | null>(null); let peekCreatureId = $state<string | null>(null);
/** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false);
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -105,7 +107,12 @@
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); const attackVsWall = $derived(
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
);
const edgeSelectMode = $derived(
(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode,
);
const cellSelectMode = $derived( const cellSelectMode = $derived(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
); );
@@ -117,6 +124,7 @@
} }
function clearSelection() { function clearSelection() {
punchWallMode = false;
ambushVia = null; ambushVia = null;
ambushTrigger = null; ambushTrigger = null;
ambushSpell = null; ambushSpell = null;
@@ -461,11 +469,17 @@
} }
function clickEdge(cell: { x: number; y: number }, side: Side) { function clickEdge(cell: { x: number; y: number }, side: Side) {
if (punchWallMode) {
dispatch({ type: "punchWall", cell, side });
punchWallMode = false;
return;
}
if (!selectedCard || !edgeSelectMode) return; if (!selectedCard || !edgeSelectMode) return;
dispatch({ dispatch({
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side }, target: { kind: "edge", cell, side },
...(attackVsWall && attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
}); });
clearSelection(); clearSelection();
} }
@@ -1105,8 +1119,8 @@
{#if selectedDef} {#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong> <strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if} {#if edgeSelectMode}<span>— click a wall line</span>{/if}
{#if selectedDef.cardType === "attack" && !edgeSelectMode && !cellSelectMode} {#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
<span>— click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"}</span> <span>— click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
{/if} {/if}
{#if attachedMods.length > 0} {#if attachedMods.length > 0}
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span> <span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
@@ -1183,6 +1197,9 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button> Pick up {cardDef(obj.cardId).name}</button>
{/each} {/each}
<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>
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button> <button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
<label class="inline draw-pick"> <label class="inline draw-pick">
draw draw
+22
View File
@@ -312,6 +312,21 @@
{/if} {/if}
{/each} {/each}
<!-- battle damage: cracks spread as a wall or door takes attacks -->
{#each Object.entries(view.wallDamage) as [key, dmg] (key)}
{@const kind = key.split(":")[0]}
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"}
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
class="crack" style:opacity={0.35 + frac * 0.65} />
{:else}
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
class="crack" style:opacity={0.35 + frac * 0.65} />
{/if}
{/each}
<!-- illusions YOU know are fake: ghostly dashed lines --> <!-- illusions YOU know are fake: ghostly dashed lines -->
{#each view.knownIllusionEdges as key (key)} {#each view.knownIllusionEdges as key (key)}
{@const kind = key.split(":")[0]} {@const kind = key.split(":")[0]}
@@ -604,6 +619,13 @@
text-anchor: middle; pointer-events: none; text-anchor: middle; pointer-events: none;
} }
.carried { fill: gold; stroke: #111; stroke-width: 1; } .carried { fill: gold; stroke: #111; stroke-width: 1; }
.crack {
stroke: #efe8d4;
stroke-width: 2;
stroke-dasharray: 3 5;
stroke-linecap: round;
pointer-events: none;
}
.edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; } .edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
.edge-hit:hover { fill: rgba(30, 120, 240, 0.5); } .edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
</style> </style>
+4
View File
@@ -129,6 +129,10 @@ export function humanize(e: GameEvent): string | null {
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`; 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 "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 "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
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}.`;
}
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null; case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`; case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;