Credibility pass: the collapse loses its scaffolding

Blind reviews over the rev-37..40 features and the gate collapse. The
scripted surgery's scars come out: bare brace blocks unwrapped (with one
hiding an unreachable scrambleHands), the amputated comment healed, the
dead events parameter unthreaded from the illusion-sight chain, the
laziness probe in perceivedBoard collapsed to the rule it always produced,
GameView.deckRev retired with its last reader, the ward's arming stub
told honestly, and every comment or test title still citing the retired
revision numbering reworded to the rule it guards. Rim mechanics unify on
rimWarpMouth and breachRim; the test suite gains plainRimWall and
pushSustained and loses its loop-shaped scars and rev-named helpers.
No behavior changes; 255 tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 13:04:01 -04:00
co-authored by Claude Fable 5
parent 8a50ff7f36
commit 4a91d56d24
17 changed files with 367 additions and 452 deletions
+9 -13
View File
@@ -1318,10 +1318,10 @@ export function automatonCommand(
} }
} }
// IDIOT's curse: the maze no longer forces the feet, so the // IDIOT's curse: the engine never forces the feet, so the clockwork
// clockwork honors the march to its own gold itself — and when a thief // honors the march to its own gold itself — and when a thief carries
// carries that gold, DROP OBJECT (an IDIOT aid) shakes it onto the // that gold, DROP OBJECT (the one attack the curse permits) shakes it
// floor where it can finally be stood upon. // onto the floor where it can finally be stood upon.
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you); const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) { if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
const dropper = inHand(view, "drop-object"); const dropper = inHand(view, "drop-object");
@@ -1361,17 +1361,13 @@ export function automatonCommand(
pathToward(view, self.position, enemyCells, { canUnlock }); pathToward(view, self.position, enemyCells, { canUnlock });
// A shimmering illusion on the best crossing costs nothing to doubt: // A shimmering illusion on the best crossing costs nothing to doubt:
// roll the free test before spending any card on that wall. // roll the free test before spending any card on that wall.
{ const doubted = bestWallCrossing(view, self, objectives, canUnlock);
const crossing = bestWallCrossing(view, self, objectives, canUnlock); if (doubted && doubted.total < (path?.distance ?? Infinity) &&
if (crossing && crossing.total < (path?.distance ?? Infinity)) { view.illusionEdges[edgeKey(doubted.cell, doubted.side)] === "untested") {
const k = edgeKey(crossing.cell, crossing.side);
if (view.illusionEdges[k] === "untested") {
const sighted = sightedCellsFor(view); const sighted = sightedCellsFor(view);
const flanks = [crossing.cell, neighbor(crossing.cell, crossing.side)]; const flanks = [doubted.cell, neighbor(doubted.cell, doubted.side)];
if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) { if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) {
return { type: "testIllusion", cell: crossing.cell, side: crossing.side }; return { type: "testIllusion", cell: doubted.cell, side: doubted.side };
}
}
} }
} }
// A wall between here and the gold may be cheaper to remove than to walk // A wall between here and the gold may be cheaper to remove than to walk
+90 -132
View File
@@ -21,6 +21,7 @@ import {
cellKey, cellKey,
edgeKey, edgeKey,
findWarp, findWarp,
type Warp,
hasLineOfSight, hasLineOfSight,
sightBetween, sightBetween,
neighbor, neighbor,
@@ -259,7 +260,6 @@ export interface GameState {
wallDamage: Record<string, number>; wallDamage: Record<string, number>;
/** Spells stuck in slime, waiting for the next visitor (by cell key). */ /** Spells stuck in slime, waiting for the next visitor (by cell key). */
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>; slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
/** Players whose WARD is set to spring. */
/** A treasure was just grabbed and its owner holds WARD: /** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */ * the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
@@ -348,8 +348,8 @@ export function losBlockers(state: GameState): Record<string, true> {
return blockers; return blockers;
} }
/** A held-open door is an open doorway to the eye: REMOVE /** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and * considered to block L.O.S." speaks of a CLOSED door, and
* the table holds doors open precisely to cast back through them. */ * the table holds doors open precisely to cast back through them. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard { function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board; if (state.heldDoors.length === 0) return board;
@@ -377,7 +377,6 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } {
*/ */
function illusionBelief( function illusionBelief(
state: GameState, state: GameState,
events: GameEvent[],
playerId: PlayerId, playerId: PlayerId,
key: string, key: string,
): "believes" | "seesThrough" { ): "believes" | "seesThrough" {
@@ -387,35 +386,15 @@ function illusionBelief(
return wall.belief[playerId] ?? "believes"; return wall.belief[playerId] ?? "believes";
} }
/** The board as one player perceives it: believed illusions become walls. */ /** The board as one player perceives it: every illusion this viewer has not
function perceivedBoard( * seen through their own excepted stands as a wall. */
state: GameState, function perceivedBoard(state: GameState, viewerId: PlayerId): AssembledBoard {
events: GameEvent[],
viewerId: PlayerId,
sightLine?: { from: Cell; to: Cell },
): AssembledBoard {
const view = boardView(state); const view = boardView(state);
const keys = Object.keys(state.illusionWalls); const keys = Object.keys(state.illusionWalls);
if (keys.length === 0) return view; if (keys.length === 0) return view;
const edges = { ...view.edges }; const edges = { ...view.edges };
for (const key of keys) { for (const key of keys) {
const known = state.illusionWalls[key]!.belief[viewerId]; if (illusionBelief(state, viewerId, key) === "believes") edges[key] = "wall";
const isCreator = state.illusionWalls[key]!.createdBy === viewerId;
if (isCreator || known === "seesThrough") continue;
if (known === "believes") {
edges[key] = "wall";
continue;
}
// Untested: only roll if this sight line would actually cross it.
if (sightLine) {
const test = { ...view, edges: { [key]: "wall" as const } };
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
if (crossesIt) {
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
}
} else {
edges[key] = "wall"; // no sight context: treat as real until tested
}
} }
return { ...view, edges }; return { ...view, edges };
} }
@@ -429,9 +408,8 @@ function casterLos(
caster: PlayerState, caster: PlayerState,
from: Cell, from: Cell,
to: Cell, to: Cell,
events: GameEvent[] = [],
): boolean { ): boolean {
const board = openHeldDoors(state, perceivedBoard(state, events, caster.id, { from, to })); const board = openHeldDoors(state, perceivedBoard(state, caster.id));
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false; if (!displays(caster, "visionstone")) return false;
@@ -446,12 +424,12 @@ function casterLos(
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ /** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean { function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
if (casterLos(state, caster, from, to, events)) return true; if (casterLos(state, caster, from, to)) return true;
const view = boardView(state); const view = boardView(state);
for (const key of Object.keys(view.cells)) { for (const key of Object.keys(view.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number]; const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my }; const mid = { x: mx, y: my };
if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) { if (casterLos(state, caster, from, mid) && casterLos(state, caster, mid, to)) {
return true; return true;
} }
} }
@@ -594,7 +572,6 @@ export type GameEvent =
| { type: "swapFizzled"; player: PlayerId } | { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId } | { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId } | { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string } | { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId } | { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
@@ -657,7 +634,7 @@ export type Command =
| { type: "punch"; targetId: PlayerId } | { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side } | { type: "punchWall"; cell: Cell; side: Side }
| { type: "testIllusion"; cell: Cell; side: Side } | { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean } | { type: "armWard" }
| { type: "wardChoice"; play: boolean } | { type: "wardChoice"; play: boolean }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
@@ -831,6 +808,34 @@ function pairedWarpMouth(
return w ? { cell: w.to.cell, side: w.to.side } : null; return w ? { cell: w.to.cell, side: w.to.side } : null;
} }
/** The warp behind (cell, side), when that edge is a rim mouth an edge
* whose far side is off the board but whose corridor wraps around. */
function rimWarpMouth(board: AssembledBoard, cell: Cell, side: Side): Warp | undefined {
return board.cells[cellKey(neighbor(cell, side))] ? undefined : findWarp(board, cell, side);
}
/**
* Break the maze's outer rim clean through at (cell, side): the default
* wraparound connects opposite edges, so the matching far rim wall opens
* with it and a new warp joins the two mouths. Null when the far side is
* not a bare wall (nothing to break through into).
*/
function breachRim(
state: GameState, events: GameEvent[], view: AssembledBoard, cell: Cell, side: Side,
): { far: { cell: Cell; side: Side }; farKey: string; farPrior: EdgeState | null } | null {
const far = oppositePerimeter(view, cell, side);
const farKey = far ? edgeKey(far.cell, far.side) : "";
if (!far || (boardView(state).edges[farKey] ?? "open") !== "wall") return null;
const farPrior = state.edgeOverrides[farKey] ?? null;
state.edgeOverrides[farKey] = "open";
state.board.warps.push(
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
);
events.push({ type: "warpOpened", a: { cell, side }, b: far });
return { far, farKey, farPrior };
}
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = { const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
// --- Attacks: damage ------------------------------------------------------ // --- Attacks: damage ------------------------------------------------------
fireball: { fireball: {
@@ -946,7 +951,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell; const to = ctx.stack.params?.cell;
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle if (!to) return; // an ambush armed without a destination fizzles
const from = ctx.defender.position; const from = ctx.defender.position;
ctx.defender.position = to; ctx.defender.position = to;
ctx.events.push({ ctx.events.push({
@@ -1127,33 +1132,22 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
delete state.createdEdges[pkey]; delete state.createdEdges[pkey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false }); events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false });
} }
// Breaching the maze's outer rim breaks through BOTH sides — the // Breaching the maze's outer rim breaks through BOTH sides.
// default wraparound connects opposite edges, so a new warp opens const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
//. ? breachRim(state, events, view, cell, side) : null;
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; if (rim) {
if (offBoard && !pair) { delete state.createdEdges[rim.farKey];
const far = oppositePerimeter(view, cell, side); events.push({ type: "wallDestroyed", caster: caster.id, edge: rim.far, wasDoor: false });
const farKey = far ? edgeKey(far.cell, far.side) : "";
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") {
state.edgeOverrides[farKey] = "open";
delete state.createdEdges[farKey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: far, wasDoor: false });
for (const p of state.players) { for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(far.cell)) { if (p.alive && cellKey(p.position) === cellKey(rim.far.cell)) {
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical"); applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
} }
} }
for (const cr of [...state.creatures]) { for (const cr of [...state.creatures]) {
if (cellKey(cr.position) === cellKey(far.cell)) { if (cellKey(cr.position) === cellKey(rim.far.cell)) {
damageCreature(state, events, cr, 4, "collapsing wall"); damageCreature(state, events, cr, 4, "collapsing wall");
} }
} }
state.board.warps.push(
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
);
events.push({ type: "warpOpened", a: { cell, side }, b: far });
}
} }
for (const c of [cell, neighbor(cell, side)]) { for (const c of [cell, neighbor(cell, side)]) {
for (const p of state.players) { for (const p of state.players) {
@@ -1395,10 +1389,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const view = boardView(state); const view = boardView(state);
// A warp mouth at the maze's rim is a corridor too — the wraparound // A warp mouth at the maze's rim is a corridor too — the wraparound
// connects it to the opposite edge. The fire takes BOTH mouths, so the crossing burns // connects it to the opposite edge. The fire takes BOTH mouths, so
// no matter which rim the wizard steps in from. // the crossing burns no matter which rim the wizard steps in from.
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warp = offBoard ? findWarp(view, cell, side) : undefined; const warp = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) { if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
return "the fire must span a corridor between two spaces"; return "the fire must span a corridor between two spaces";
} }
@@ -1440,7 +1434,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// A rim warp mouth is a corridor too: the collapse washes // A rim warp mouth is a corridor too: the collapse washes
// both rims inward (waveFromEdge follows the tunnel). // both rims inward (waveFromEdge follows the tunnel).
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warpMouth = offBoard && findWarp(view, cell, side); const warpMouth = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the wave must span a corridor between two spaces"; return "the wave must span a corridor between two spaces";
} }
@@ -1604,7 +1598,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// create-wall's brick-over. The illusion hangs on the mouth it was // create-wall's brick-over. The illusion hangs on the mouth it was
// painted on; the far mouth shows nothing (nothing physical exists). // painted on; the far mouth shows nothing (nothing physical exists).
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warpMouth = offBoard && findWarp(view, cell, side); const warpMouth = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the illusion must span two spaces on the board"; return "the illusion must span two spaces on the board";
} }
@@ -1778,26 +1772,16 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
// A rim wall warped open bores a temporary wraparound: the // A rim wall warped open bores a temporary wraparound: the far rim
// far rim wall vanishes with it and a warp runs until turn's end. // wall vanishes with it and a warp runs until turn's end.
// (Pre-40 rim casts opened a dead edge and replay so.) const rim = !view.cells[cellKey(neighbor(cell, side))] && !findWarp(view, cell, side)
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; ? breachRim(state, events, view, cell, side) : null;
if (offBoard && !findWarp(view, cell, side)) { if (rim) {
const far = oppositePerimeter(view, cell, side);
const farKey = far ? edgeKey(far.cell, far.side) : "";
if (far && (view.edges[farKey] ?? "open") === "wall") {
state.tempWarpEdges.push({ state.tempWarpEdges.push({
key: farKey, prior: state.edgeOverrides[farKey] ?? null, key: rim.farKey, prior: rim.farPrior,
unwarp: { a: cell, aSide: side, b: far.cell, bSide: far.side }, unwarp: { a: cell, aSide: side, b: rim.far.cell, bSide: rim.far.side },
}); });
state.edgeOverrides[farKey] = "open"; events.push({ type: "wallWarpedOpen", player: caster.id, edge: rim.far });
events.push({ type: "wallWarpedOpen", player: caster.id, edge: far });
state.board.warps.push(
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
);
events.push({ type: "warpOpened", a: { cell, side }, b: far });
}
} }
return null; return null;
}, },
@@ -1852,16 +1836,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
const view = boardView(state); const view = boardView(state);
// No doors through the maze's rim: a rim door would open onto // Cells on both sides: a rim door would open onto nothing
// nothing traversable — the wraparound belongs to breaches, not doors. // traversable — the wraparound belongs to breaches, not doors.
if (!view.cells[cellKey(neighbor(cell, side))]) {
return "the door must stand between two spaces";
}
if ((view.edges[key] ?? "open") === "open") {
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the door must stand between two spaces"; return "the door must stand between two spaces";
} }
} else if (view.edges[key] !== "wall") { if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
return "a door goes into a stone wall or an open corridor"; return "a door goes into a stone wall or an open corridor";
} }
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
@@ -1969,8 +1949,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "edge") { if (cmd.target?.kind === "edge") {
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
// Doors are "small entryways in a stone wall" stone melts as // Doors are "small entryways in a stone wall": stone melts as stone.
// stone.
const current = view.edges[key]; const current = view.edges[key];
const meltable = current === "wall" || current === "door"; const meltable = current === "wall" || current === "door";
if (!meltable) return "that is not a stone wall"; if (!meltable) return "that is not a stone wall";
@@ -1991,20 +1970,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// Melting the maze's outer rim breaches it like DESTROY WALL does: // Melting the maze's outer rim breaches it like DESTROY WALL does:
// the far rim wall washes out too and a new warp opens, so the // the far rim wall washes out too and a new warp opens, so the
// wave below pours through the fresh tunnel onto both rims. // wave below pours through the fresh tunnel onto both rims.
const offBoard = !view.cells[cellKey(neighbor(cell, side))]; const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
if (offBoard && !pair) { ? breachRim(state, events, view, cell, side) : null;
const far = oppositePerimeter(view, cell, side); if (rim) {
const farKey = far ? edgeKey(far.cell, far.side) : ""; delete state.createdEdges[rim.farKey];
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") { events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: rim.far });
state.edgeOverrides[farKey] = "open";
delete state.createdEdges[farKey];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: far });
state.board.warps.push(
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
);
events.push({ type: "warpOpened", a: { cell, side }, b: far });
}
} }
// "Wall turns into a WATERWALL with a range and damage of 2." // "Wall turns into a WATERWALL with a range and damage of 2."
waveFromEdge(state, events, cell, side, 2); waveFromEdge(state, events, cell, side, 2);
@@ -2141,7 +2111,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell; const to = ctx.stack.params?.cell;
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle if (!to) return; // an ambush armed without a destination fizzles
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
const from = ctx.defender.position; const from = ctx.defender.position;
ctx.defender.position = to; ctx.defender.position = to;
@@ -2217,16 +2187,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped) return; if (ctx.fullyStopped) return;
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : []; const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
{
const order = turnOrderFrom(ctx.state, ctx.attacker.id); const order = turnOrderFrom(ctx.state, ctx.attacker.id);
const queue = order.filter((id) => const queue = order.filter((id) =>
id !== ctx.attacker.id && id !== ctx.defender.id && id !== ctx.attacker.id && id !== ctx.defender.id &&
ctx.state.players.find((p) => p.id === id)!.alive); ctx.state.players.find((p) => p.id === id)!.alive);
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue }; ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
finishChaosIfReady(ctx.state, ctx.events); finishChaosIfReady(ctx.state, ctx.events);
return;
}
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
}, },
}, },
"illusionary-attack": { "illusionary-attack": {
@@ -2263,9 +2229,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped) return; if (ctx.fullyStopped) return;
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";"); const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
// "Swap any two carried items": every movable object trades — daggers, // "Swap any two carried items": every movable object trades —
// rocks, wands, stones — and a carried TREASURE is an // daggers, rocks, wands, stones — and a carried TREASURE is an item
// item too, named by the "treasure" token (never in older ledgers). // too, named by the "treasure" token.
const tradable = (id: string) => isMovableObject(id); const tradable = (id: string) => isMovableObject(id);
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" }; type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
const side = (p: PlayerState, id: string): TradeSide | null => { const side = (p: PlayerState, id: string): TradeSide | null => {
@@ -2430,7 +2396,6 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// "Signify their new directions by placing the A tokens on one set of // "Signify their new directions by placing the A tokens on one set of
// exits & the B tokens on the other" — the two exits you choose become a // exits & the B tokens on the other" — the two exits you choose become a
// pair, and their former partners pair with each other. // pair, and their former partners pair with each other.
// Earlier revisions swapped the two exits' destinations instead.
resolve: (state, events, caster, cmd) => { resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell; const a = cmd.params?.cell;
const bT = cmd.target; const bT = cmd.target;
@@ -2475,7 +2440,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const aim = cmd.target.cell; const aim = cmd.target.cell;
const view = boardView(state); const view = boardView(state);
if (!view.cells[cellKey(aim)]) return "off the board"; if (!view.cells[cellKey(aim)]) return "off the board";
if (!casterLos(state, caster, caster.position, aim, events)) return "no line of sight"; if (!casterLos(state, caster, caster.position, aim)) return "no line of sight";
state.turn.attackUsed = true; state.turn.attackUsed = true;
const clampToBoard = (c: Cell): Cell => { const clampToBoard = (c: Cell): Cell => {
@@ -2621,11 +2586,11 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
/** /**
* How hard a wave still pushes someone it finds `dist` cells from its * How hard a wave still pushes someone it finds `dist` cells from its
* source: the force spent reaching them is gone, so a * source: the force spent reaching them is gone, so a range-2 wave throws
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far * its adjacent victim 2 spaces but a victim at its far edge only 1 and
* edge only 1 and never converts spent force into crush damage. * never converts spent force into crush damage.
*/ */
function waveForce(state: GameState, range: number, dist: number): number { function waveForce(range: number, dist: number): number {
return range - dist; return range - dist;
} }
@@ -2633,8 +2598,7 @@ function waveForce(state: GameState, range: number, dist: number): number {
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void { function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
// A warp mouth's "far side" is the opposite rim: the collapse washes both // A warp mouth's "far side" is the opposite rim: the collapse washes both
// mouths inward. A plain edge washes its two adjacent cells apart. // mouths inward. A plain edge washes its two adjacent cells apart.
const warp = !boardView(state).cells[cellKey(neighbor(cell, side))] const warp = rimWarpMouth(boardView(state), cell, side);
? findWarp(boardView(state), cell, side) : undefined;
const pushes: { start: Cell; dir: Side }[] = warp const pushes: { start: Cell; dir: Side }[] = warp
? [ ? [
{ start: cell, dir: opposite(side) }, { start: cell, dir: opposite(side) },
@@ -2647,7 +2611,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
for (const { start, dir } of pushes) { for (const { start, dir } of pushes) {
let probe = start; let probe = start;
for (let dist = 0; dist < range; dist++) { for (let dist = 0; dist < range; dist++) {
const force = waveForce(state, range, dist); const force = waveForce(range, dist);
for (const p of state.players) { for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force); if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
} }
@@ -2675,7 +2639,7 @@ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range
let probe = center; let probe = center;
for (let dist = 0; dist < range; dist++) { for (let dist = 0; dist < range; dist++) {
probe = neighbor(probe, dir); probe = neighbor(probe, dir);
const force = waveForce(state, range, dist); const force = waveForce(range, dist);
for (const p of state.players) { for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force); if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
} }
@@ -2708,7 +2672,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
if (!view.cells[cellKey(at)]) return "off the board"; if (!view.cells[cellKey(at)]) return "off the board";
if (state.squareContents[cellKey(at)]) return "that square is blocked"; if (state.squareContents[cellKey(at)]) return "that square is blocked";
if (creatureAt(state, at)) return "a creature is already there"; if (creatureAt(state, at)) return "a creature is already there";
if (!casterLos(state, caster, caster.position, at, events)) return "no line of sight"; if (!casterLos(state, caster, caster.position, at)) return "no line of sight";
state.turn.attackUsed = true; state.turn.attackUsed = true;
spawnCreature(state, events, kind, caster.id, at); spawnCreature(state, events, kind, caster.id, at);
return null; return null;
@@ -2784,9 +2748,9 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S
} }
} }
/** The wave carries monsters as it carries wizards: washed /** The wave carries monsters as it carries wizards: washed back, and
* back, and crushed a point per space the maze refuses them. Fire imps * crushed a point per space the maze refuses them. Fire imps never reach
* never reach this water destroys them outright. */ * this water destroys them outright. */
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void { function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
const view = boardView(state); const view = boardView(state);
let moved = 0; let moved = 0;
@@ -3170,7 +3134,6 @@ function remapState(
if (t.position && inSector(t.position)) t.position = mapCell(t.position); if (t.position && inSector(t.position)) t.position = mapCell(t.position);
} }
// A moving sector carries everything standing on it. // A moving sector carries everything standing on it.
{
for (const c of state.creatures) { for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position); if (inSector(c.position)) c.position = mapCell(c.position);
} }
@@ -3184,7 +3147,6 @@ function remapState(
if (inSector(w.a)) w.a = mapCell(w.a); if (inSector(w.a)) w.a = mapCell(w.a);
if (inSector(w.b)) w.b = mapCell(w.b); if (inSector(w.b)) w.b = mapCell(w.b);
} }
}
} }
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ /** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
@@ -3805,7 +3767,6 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const p = activePlayer(state); const p = activePlayer(state);
const events: GameEvent[] = []; const events: GameEvent[] = [];
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // move ... bumping into a wall counts as one space of movement."
if (isBlinded(state, p)) { if (isBlinded(state, p)) {
@@ -3820,7 +3781,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
if (isBlinded(state, p)) return blindBump(state, events, p, direction); if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("the wall shimmers oddly — test your eyes before you can pass"); return err("the wall shimmers oddly — test your eyes before you can pass");
} }
if (shimmer && illusionBelief(state, events, p.id, key) === "believes") { if (shimmer && illusionBelief(state, p.id, key) === "believes") {
if (isBlinded(state, p)) return blindBump(state, events, p, direction); if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked by wall"); return err("blocked by wall");
} }
@@ -4300,9 +4261,8 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
} }
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */ /** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** Compat stub for stale clients: the Ward has no arming step. */
function doArmWard(): CommandResult { function doArmWard(): CommandResult {
// The card is read literally: the Ward is played in the moment of the
// grab, never set ahead.
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed"); return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
} }
@@ -4352,7 +4312,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is"); if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall"); if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = []; const events: GameEvent[] = [];
const board = openHeldDoors(state, perceivedBoard(state, events, p.id)); const board = openHeldDoors(state, perceivedBoard(state, p.id));
if (!isAdjacentToEdge(p.position, cell, side) && if (!isAdjacentToEdge(p.position, cell, side) &&
!losToEdge(board, p.position, cell, side)) { !losToEdge(board, p.position, cell, side)) {
return err("you cannot see that wall from here"); return err("you cannot see that wall from here");
@@ -4788,7 +4748,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.requiresLos) { if (effect.requiresLos) {
const sighted = mods.aroundCorner const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position, preEvents) ? bentLos(state, caster, caster.position, target.position, preEvents)
: casterLos(state, caster, caster.position, target.position, preEvents); : casterLos(state, caster, caster.position, target.position);
if (!sighted) return err("no line of sight to the target"); if (!sighted) return err("no line of sight to the target");
} }
// Attacking someone breaks any BUDDY pact you swore to them. // Attacking someone breaks any BUDDY pact you swore to them.
@@ -6255,7 +6215,6 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
// "If you have SPEED on you while under the influence of a duration // "If you have SPEED on you while under the influence of a duration
// spell, it uses up a turn of that duration" — recipient-counted (FAQ). // spell, it uses up a turn of that duration" — recipient-counted (FAQ).
// Self-cast durations already burned in beginTurnFor. // Self-cast durations already burned in beginTurnFor.
{
const surviving: SustainedEffect[] = []; const surviving: SustainedEffect[] = [];
for (const s of state.sustained) { for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) { if (s.targetId === p.id && s.casterId !== p.id) {
@@ -6268,7 +6227,6 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
surviving.push(s); surviving.push(s);
} }
state.sustained = surviving; state.sustained = surviving;
}
events.push({ type: "extraTurnStarted", player: p.id }); events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round }); events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events }; return { ok: true, state, events };
-4
View File
@@ -42,9 +42,6 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null; winReason: "treasures" | "lastStanding" | null;
turn: TurnState; turn: TurnState;
activePlayerId: PlayerId; activePlayerId: PlayerId;
/** The game's rules revision (a fresh count from 1; gates return if the
* rules ever fork again while games are live). */
deckRev: number;
/** Board with dynamic wall changes already merged in. */ /** Board with dynamic wall changes already merged in. */
board: AssembledBoard; board: AssembledBoard;
players: PlayerPublicView[]; players: PlayerPublicView[];
@@ -139,7 +136,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
revealedHands: state.phase === "finished" revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]])) ? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
: null, : null,
deckRev: state.config.deckRev ?? 1,
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length, deckCount: state.deck.length,
discardCount: state.discard.length, discardCount: state.discard.length,
+8 -7
View File
@@ -9,6 +9,7 @@ import {
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId { function actingSeat(state: GameState): PlayerId {
@@ -224,7 +225,7 @@ describe("the clockwork guards gold only within sight", () => {
}); });
describe("the clockwork honors IDIOT's march", () => { describe("the clockwork honors IDIOT's march", () => {
// Rev 37 retired the engine's steering, so the duty falls to the brain. // The engine never steers a cursed wizard's feet; the duty is the brain's.
function cursedBot() { function cursedBot() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
// Past round 1 (no combat) and around to the bot's turn. // Past round 1 (no combat) and around to the bot's turn.
@@ -238,10 +239,10 @@ describe("the clockwork honors IDIOT's march", () => {
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
state = r.state; state = r.state;
} }
state.sustained.push({ pushSustained(state, {
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot", id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
remainingTurns: 9999, data: {}, remainingTurns: 9999, data: {},
} as never); });
return state; return state;
} }
@@ -593,10 +594,10 @@ describe("a pact once signed is honored", () => {
const prey = state.players.find((p) => p.id === "other")!; const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position }; prey.position = { ...bot.position };
// The pact already stands; a fireball waits in hand as temptation. // The pact already stands; a fireball waits in hand as temptation.
state.sustained.push({ pushSustained(state, {
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other", id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
remainingTurns: 1000, data: {}, remainingTurns: 1000, data: {},
} as never); });
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }]; bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Anything but an attack on the pacted wizard: no cast at them, no punch. // Anything but an attack on the pacted wizard: no cast at them, no punch.
@@ -645,10 +646,10 @@ describe("the clockwork flees the dread", () => {
const grim = state.players.find((p) => p.id === "grim")!; const grim = state.players.find((p) => p.id === "grim")!;
grim.position = { x: 2, y: 5 }; grim.position = { x: 2, y: 5 };
bot.position = { x: 2, y: 7 }; // two spaces inside the dread bot.position = { x: 2, y: 7 }; // two spaces inside the dread
state.sustained.push({ pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim", id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
remainingTurns: 5, data: {}, remainingTurns: 5, data: {},
} as never); });
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).toBe("move"); expect(cmd?.type).toBe("move");
if (cmd?.type === "move") { if (cmd?.type === "move") {
+1 -1
View File
@@ -281,7 +281,7 @@ describe("bricking over a warp mouth", () => {
}); });
}); });
describe("breaching the rim (rules rev 19)", () => { describe("breaching the rim", () => {
it("destroying a perimeter wall opens both sides as a new warp", () => { it("destroying a perimeter wall opens both sides as a new warp", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
+18 -21
View File
@@ -308,7 +308,7 @@ describe("stored-log compatibility", () => {
}); });
}); });
describe("deck revisions", () => { describe("the two-player deck", () => {
it("two-player games shed LIFESAVER; bigger tables keep it", () => { it("two-player games shed LIFESAVER; bigger tables keep it", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] }); const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
const inGame = (s: typeof two.state) => const inGame = (s: typeof two.state) =>
@@ -391,7 +391,7 @@ describe("the ward window and chaos shields", () => {
const thief = activePlayer(state); const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!; const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0); giveCard(state, owner.id, "ward", "W", 0);
expect(applyCommand(state, owner.id, { type: "armWard", armed: true }).ok).toBe(false); expect(applyCommand(state, owner.id, { type: "armWard" }).ok).toBe(false);
const treasure = state.treasures.find((t) => t.owner === owner.id)!; const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! }; thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" }); state = must(state, thief.id, { type: "pickUpTreasure" });
@@ -452,7 +452,6 @@ describe("the ward window and chaos shields", () => {
expect(state.chaosPending).toBeNull(); expect(state.chaosPending).toBeNull();
expect(defenderHand()).toEqual(kept); expect(defenderHand()).toEqual(kept);
}); });
}); });
describe("zero-damage utility attacks", () => { describe("zero-damage utility attacks", () => {
@@ -493,7 +492,6 @@ describe("zero-damage utility attacks", () => {
target: { kind: "player", playerId: defender }, target: { kind: "player", playerId: defender },
}); });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" }); state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
// A total stop settles on the attacker's pass.
const result = applyCommand(state, attacker, { type: "pass" }); const result = applyCommand(state, attacker, { type: "pass" });
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved"); const resolved = result.events.find((e) => e.type === "attackResolved");
@@ -515,7 +513,6 @@ describe("teleport as a counteraction", () => {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
}); });
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } }); state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
// The escape is a total stop: the attacker's pass settles it.
state = must(state, attacker, { type: "pass" }); state = must(state, attacker, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!; const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15); expect(after.life).toBe(15);
@@ -618,12 +615,12 @@ describe("slime holds spells", () => {
}); });
describe("answering counteractions (FAQ rulings)", () => { describe("answering counteractions (FAQ rulings)", () => {
function rev6() { function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
} }
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => { it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
let { state } = rev6(); let { state } = freshGame();
state = toRound2(state); state = toRound2(state);
const { attacker, defender } = faceOff(state); const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
@@ -643,7 +640,7 @@ describe("answering counteractions (FAQ rulings)", () => {
}); });
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => { it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
let { state } = rev6(); let { state } = freshGame();
state = toRound2(state); state = toRound2(state);
const { attacker, defender } = faceOff(state); const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
@@ -658,8 +655,8 @@ describe("answering counteractions (FAQ rulings)", () => {
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/); if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
}); });
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => { it("ANTI-ANTI does not work against a teleport escape", () => {
let { state } = rev6(); let { state } = freshGame();
state = toRound2(state); state = toRound2(state);
const { attacker, defender } = faceOff(state); const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
@@ -680,7 +677,7 @@ describe("answering counteractions (FAQ rulings)", () => {
}); });
}); });
describe("speed and warp-token creation (rules rev 8)", () => { describe("speed and warp-token creation", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => { it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state); state = toRound2(state);
@@ -715,7 +712,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
}); });
}); });
describe("redirection (rules rev 9)", () => { describe("redirection", () => {
it("the two chosen exits connect; their old partners pair with each other", () => { it("the two chosen exits connect; their old partners pair with each other", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state); state = toRound2(state);
@@ -740,8 +737,8 @@ describe("redirection (rules rev 9)", () => {
}); });
}); });
describe("total stops end the exchange (rules rev 10)", () => { describe("total stops end the exchange", () => {
function rev10Game() { function faceOffRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state); state = toRound2(state);
const { attacker, defender } = faceOff(state); const { attacker, defender } = faceOff(state);
@@ -749,7 +746,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
} }
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => { it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
let { state, attacker, defender } = rev10Game(); let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!; const d = state.players.find((p) => p.id === defender)!;
const lifeBefore = d.life; const lifeBefore = d.life;
@@ -763,7 +760,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
}); });
it("a partial counter still invites the defender to stack more", () => { it("a partial counter still invites the defender to stack more", () => {
let { state, attacker, defender } = rev10Game(); let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!; const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" }; d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
@@ -776,7 +773,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
}); });
it("a nullified shield is no stop — the exchange returns to the defender", () => { it("a nullified shield is no stop — the exchange returns to the defender", () => {
let { state, attacker, defender } = rev10Game(); let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball"); const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!; const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!; const d = state.players.find((p) => p.id === defender)!;
@@ -839,7 +836,7 @@ describe("escapes and elemental walls as counteractions", () => {
const lifeBefore = state.players.find((p) => p.id === defender)!.life; const lifeBefore = state.players.find((p) => p.id === defender)!.life;
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" }); state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
state = must(state, attacker, { type: "pass" }); state = must(state, attacker, { type: "pass" });
// A total stop: the attacker's declined answer resolves at once (rev 10+). // A total stop: the attacker's declined answer resolves at once.
expect(state.stack).toBeNull(); expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore); expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
}); });
@@ -894,7 +891,7 @@ describe("empathy as a counteraction", () => {
}); });
}); });
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => { describe("empathy mirrors resolution-hook blows", () => {
it("a believed illusion bites both wizards", () => { it("a believed illusion bites both wizards", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -997,7 +994,7 @@ describe("a reflected lightning blast ends the caster's turn", () => {
}); });
}); });
describe("fireball burns only the stones in play (rules rev 33)", () => { describe("fireball burns only the stones in play", () => {
it("a displayed stone dies; a hidden one stays secret and safe", () => { it("a displayed stone dies; a hidden one stays secret and safe", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state); state = toRound2(state);
@@ -1015,7 +1012,7 @@ describe("fireball burns only the stones in play (rules rev 33)", () => {
}); });
}); });
describe("stone dead counts only the stones in play (rules rev 34)", () => { describe("stone dead counts only the stones in play", () => {
it("hidden stones neither add damage nor betray their count", () => { it("hidden stones neither add damage nor betray their count", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
+15 -16
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */ /** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -139,7 +139,6 @@ describe("monsters", () => {
break; break;
} }
} }
// The touch opens the victim's counteraction window; they take it raw.
state = must(state, enemy2.id, { type: "pass" }); state = must(state, enemy2.id, { type: "pass" });
const bitten = state.players.find((p) => p.id !== me)!; const bitten = state.players.find((p) => p.id !== me)!;
expect(bitten.life).toBe(13); expect(bitten.life).toBe(13);
@@ -299,7 +298,7 @@ describe("expansion support cards", () => {
}); });
describe("counteracting a creature's blow", () => { describe("counteracting a creature's blow", () => {
function rev4() { function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
} }
function wraithOnVictim(state: GameState) { function wraithOnVictim(state: GameState) {
@@ -317,7 +316,7 @@ describe("counteracting a creature's blow", () => {
} }
it("BLUNT halves the wraith's touch; the card theft still lands", () => { it("BLUNT halves the wraith's touch; the card theft still lands", () => {
let { state } = rev4(); let { state } = freshGame();
const rig = wraithOnVictim(state); const rig = wraithOnVictim(state);
state = rig.state; state = rig.state;
giveCard(state, rig.victim, "blunt", "B", 0); giveCard(state, rig.victim, "blunt", "B", 0);
@@ -333,7 +332,7 @@ describe("counteracting a creature's blow", () => {
}); });
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => { it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
let { state } = rev4(); let { state } = freshGame();
const rig = wraithOnVictim(state); const rig = wraithOnVictim(state);
state = rig.state; state = rig.state;
giveCard(state, rig.victim, "full-reflection", "FR", 0); giveCard(state, rig.victim, "full-reflection", "FR", 0);
@@ -348,7 +347,7 @@ describe("counteracting a creature's blow", () => {
}); });
}); });
describe("big man (rules rev 5)", () => { describe("big man", () => {
function bigRig() { function bigRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -465,7 +464,7 @@ describe("monsters roll to hit the hidden", () => {
return { state, me, victim, skeletonId: bones.id }; return { state, me, victim, skeletonId: bones.id };
} }
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => { it("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible(); let { state, me, victim, skeletonId } = creatureVsInvisible();
const rngBefore = JSON.stringify(state.rng); const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id }); state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
@@ -475,7 +474,7 @@ describe("monsters roll to hit the hidden", () => {
}); });
}); });
describe("elimination sweeps the board either way (rules rev 14)", () => { describe("elimination sweeps the board either way", () => {
it("a treasure-eliminated wizard's fire imp vanishes with them", () => { it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
const victim = state.players.find((p) => p.id === "c")!; const victim = state.players.find((p) => p.id === "c")!;
@@ -536,7 +535,7 @@ describe("creatures walk open doorways", () => {
}); });
}); });
describe("the wave carries monsters (rules rev 17)", () => { describe("the wave carries monsters", () => {
it("a cornered skeleton takes the waterwall crush", () => { it("a cornered skeleton takes the waterwall crush", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -575,7 +574,7 @@ describe("the wave carries monsters (rules rev 17)", () => {
}); });
}); });
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => { describe("the democratic monster's claw survives the first wizard's death", () => {
it("refreshes each round even with the roll-off winner dead", () => { it("refreshes each round even with the roll-off winner dead", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -602,7 +601,7 @@ describe("the democratic monster's claw survives the first wizard's death (rules
}); });
}); });
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => { describe("a mid-round democratic monster claws on the next turn", () => {
it("creation spends the turn, not the round", () => { it("creation spends the turn, not the round", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -656,7 +655,7 @@ describe("collapsing walls crush monsters too", () => {
throw new Error("setup: no interior wall found"); throw new Error("setup: no interior wall found");
} }
it("rev 21: the adjacent troll takes the four points", () => { it("the adjacent troll takes the four points", () => {
const state = wallRig(); const state = wallRig();
const troll = state.creatures.find((c) => c.id === "tr1"); const troll = state.creatures.find((c) => c.id === "tr1");
// Four points on a six-point troll: hurt but standing (or regenerating). // Four points on a six-point troll: hurt but standing (or regenerating).
@@ -681,7 +680,7 @@ describe("the self-stack resolves on a pass", () => {
return { state: r.state, creator: creator.id }; return { state: r.state, creator: creator.id };
} }
it("rev 22: passing your own monster's touch takes the claw and moves on", () => { it("passing your own monster's touch takes the claw and moves on", () => {
const { state, creator } = selfTouch(); const { state, creator } = selfTouch();
if (!state.stack) return; // a wall between: the touch never opened if (!state.stack) return; // a wall between: the touch never opened
expect(state.stack.attackerId).toBe(creator); expect(state.stack.attackerId).toBe(creator);
@@ -693,16 +692,16 @@ describe("the self-stack resolves on a pass", () => {
}); });
}); });
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => { describe("fear holds off monsters and unwilling feet alike", () => {
it("a commanded monster cannot close within three of the fearsome", () => { it("a commanded monster cannot close within three of the fearsome", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!; const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!; const b = state.players.find((p) => p.id === "b")!;
// b radiates fear; a's troll stands exactly four away, aimed straight at b. // b radiates fear; a's troll stands exactly four away, aimed straight at b.
state.sustained.push({ pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b", id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {}, remainingTurns: 5, data: {},
} as never); });
b.position = { x: 2, y: 5 }; b.position = { x: 2, y: 5 };
a.position = { x: 0, y: 0 }; a.position = { x: 0, y: 0 };
state.creatures.push({ state.creatures.push({
@@ -339,7 +339,6 @@ describe("cast modifiers", () => {
target: { kind: "player", playerId: defender }, target: { kind: "player", playerId: defender },
}); });
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" }); state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
// REVERSE is a total stop of the harm: the attacker's pass settles it.
state = must(state, attacker, { type: "pass" }); state = must(state, attacker, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!; const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(19); // gained 4 instead of losing it expect(d.life).toBe(19); // gained 4 instead of losing it
@@ -430,7 +429,7 @@ describe("a held door is an open doorway to the eye", () => {
throw new Error("setup: seed 42 grew a maze with no doors"); throw new Error("setup: seed 42 grew a maze with no doors");
} }
it("rev 15: the holder blasts the pursuer through the doorway", () => { it("the holder blasts the pursuer through the held doorway", () => {
let { state, cell, side, holder, pursuer } = sightRig(); let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock"); const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, { state = must(state, holder, {
@@ -164,7 +164,7 @@ describe("expansion combat cards", () => {
} }
}); });
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => { it("idiot forbids item handling and punches but allows counteractions", () => {
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -216,7 +216,7 @@ describe("expansion combat cards", () => {
.filter((s) => stepTarget(board, d.position, s).kind === "step"); .filter((s) => stepTarget(board, d.position, s).kind === "step");
expect(open.length).toBeGreaterThanOrEqual(2); expect(open.length).toBeGreaterThanOrEqual(2);
// Plant the victim's own gold one step out one way; walk the other way. // Plant the victim's own gold one step out one way; walk the other way.
// Pre-37 steering would have overridden the request and taken the gold.
const toward = stepTarget(board, d.position, open[0]!); const toward = stepTarget(board, d.position, open[0]!);
const away = stepTarget(board, d.position, open[1]!); const away = stepTarget(board, d.position, open[1]!);
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable"); if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
@@ -228,7 +228,6 @@ describe("expansion combat cards", () => {
}); });
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => { it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
{
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -242,23 +241,22 @@ describe("expansion combat cards", () => {
const id = giveCard(state, attacker, "idiot"); const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id); state = castAt(state, attacker, defender, id);
state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, attacker, { type: "endTurn", draw: 0 });
// An attack for its own sake stays forbidden at every revision. // An attack for its own sake stays forbidden.
const fb = giveCard(state, defender, "fireball", "F", 0); const fb = giveCard(state, defender, "fireball", "F", 0);
expect(applyCommand(state, defender, { expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false); }).ok).toBe(false);
// Shaking YOUR OWN gold out of the thief's arms serves the march — // Shaking YOUR OWN gold out of the thief's arms serves the march —
// while carried it cannot be stood upon — but only from rev 37 on. // while carried it cannot be stood upon.
const dr = giveCard(state, defender, "drop-object", "D", 1); const dr = giveCard(state, defender, "drop-object", "D", 1);
const r = applyCommand(state, defender, { const r = applyCommand(state, defender, {
type: "cast", instanceId: dr.instanceId, type: "cast", instanceId: dr.instanceId,
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" }, target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
}); });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
}
}); });
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => { it("idiot has no effect on a victim carrying their own treasure", () => {
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -500,7 +498,7 @@ describe("swap meet trades treasures too", () => {
}); });
}); });
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => { describe("full reflection hands the swap meet choice to the reflector", () => {
function reflectedSwapRig(reflectorChoice?: string) { function reflectedSwapRig(reflectorChoice?: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
@@ -547,7 +545,7 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)",
}); });
}); });
describe("go away routs around walls (rules rev 36)", () => { describe("go away routs around walls", () => {
it("a victim against a wall bends the line instead of standing still", () => { it("a victim against a wall bends the line instead of standing still", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state); state = toRound2(state);
+3 -41
View File
@@ -3,7 +3,7 @@ import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../s
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view"; import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
describe("expansion terrain", () => { describe("expansion terrain", () => {
it("killer ooze burns on entry and can drop you on your face", () => { it("killer ooze burns on entry and can drop you on your face", () => {
@@ -163,7 +163,6 @@ describe("expansion terrain", () => {
}); });
it("wall of fire takes a rim warp — both mouths burn the crossing", () => { it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
{
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -187,11 +186,9 @@ describe("expansion terrain", () => {
const after = state.players.find((p) => p.id === me.id)!; const after = state.players.find((p) => p.id === me.id)!;
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell)); expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
expect(after.life).toBe(11); expect(after.life).toBe(11);
}
}); });
it("waterwall takes a rim warp — the collapse washes both rims", () => { it("waterwall takes a rim warp — the collapse washes both rims", () => {
{
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -211,11 +208,9 @@ describe("expansion terrain", () => {
const otherAfter = r.state.players.find((p) => p.id === other.id)!; const otherAfter = r.state.players.find((p) => p.id === other.id)!;
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell)); expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell)); expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
}
}); });
it("illusion wall hangs on a rim warp mouth", () => { it("illusion wall hangs on a rim warp mouth", () => {
{
const { state } = createGame({ const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -231,30 +226,14 @@ describe("expansion terrain", () => {
if (r.ok) { if (r.ok) {
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined(); expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
} }
}
}); });
it("stone to water breaches the outer rim, opening a warp", () => { it("stone to water breaches the outer rim, opening a warp", () => {
{
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
const me = activePlayer(state); const me = activePlayer(state);
const board = boardView(state); const rim = plainRimWall(state);
// Stand at a plain rim wall (an off-board edge that is no warp mouth).
let rim: { cell: Cell; side: Side } | null = null;
outer: for (const k of Object.keys(board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (board.cells[cellKey(n)]) continue;
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
rim = { cell: { x, y }, side };
break outer;
}
}
if (!rim) throw new Error("setup: no plain rim wall found");
me.position = { ...rim.cell }; me.position = { ...rim.cell };
const warpsBefore = state.board.warps.length; const warpsBefore = state.board.warps.length;
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0); const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
@@ -264,28 +243,14 @@ describe("expansion terrain", () => {
}); });
// The far rim melted with it and the wraparound now runs. // The far rim melted with it and the wraparound now runs.
expect(state.board.warps.length).toBe(warpsBefore + 2); expect(state.board.warps.length).toBe(warpsBefore + 2);
}
}); });
it("no doors through the rim", () => { it("no doors through the rim", () => {
{
const { state } = createGame({ const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
const me = activePlayer(state); const me = activePlayer(state);
const board = boardView(state); const rim = plainRimWall(state);
let rim: { cell: Cell; side: Side } | null = null;
outer: for (const k of Object.keys(board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (!board.cells[cellKey(n)] && board.edges[edgeKey({ x, y }, side)] === "wall") {
rim = { cell: { x, y }, side };
break outer;
}
}
}
if (!rim) throw new Error("setup: no rim wall found");
me.position = { ...rim.cell }; me.position = { ...rim.cell };
const cd = giveCard(state, me.id, "create-door", "CD", 0); const cd = giveCard(state, me.id, "create-door", "CD", 0);
const r = applyCommand(state, me.id, { const r = applyCommand(state, me.id, {
@@ -293,11 +258,9 @@ describe("expansion terrain", () => {
target: { kind: "edge", cell: rim.cell, side: rim.side }, target: { kind: "edge", cell: rim.cell, side: rim.side },
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}
}); });
it("stone to water melts a door — a small entryway in a stone wall", () => { it("stone to water melts a door — a small entryway in a stone wall", () => {
{
const { state } = createGame({ const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
@@ -327,7 +290,6 @@ describe("expansion terrain", () => {
expect(boardView(r.state).edges[key] ?? "open").toBe("open"); expect(boardView(r.state).edges[key] ?? "open").toBe("open");
expect(r.state.doorStates[key]).toBeUndefined(); expect(r.state.doorStates[key]).toBeUndefined();
} }
}
}); });
}); });
+5 -5
View File
@@ -227,7 +227,7 @@ describe("treasures and victory", () => {
}); });
}); });
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => { describe("elimination by lost treasures drops what the fallen carried", () => {
it("the carried treasure lands where the wizard stood", () => { it("the carried treasure lands where the wizard stood", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
const A = state.players.find((p) => p.id === "a")!; const A = state.players.find((p) => p.id === "a")!;
@@ -265,7 +265,7 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)",
}); });
}); });
describe("the Ward is played in the moment (rules rev 31)", () => { describe("the Ward is played in the moment", () => {
function grabRig() { function grabRig() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
const thief = state.players.find((p) => p.id === "thief")!; const thief = state.players.find((p) => p.id === "thief")!;
@@ -306,15 +306,15 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true); expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
}); });
it("arming is refused in this vintage", () => { it("arming ahead is refused the ward waits for the grab", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0); 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 }); const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard" });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });
describe("an ambushed teleport carries its destination (rules rev 35)", () => { describe("an ambushed teleport carries its destination", () => {
it("the trap springs and the victim lands where the trapper said", () => { it("the trap springs and the victim lands where the trapper said", () => {
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) { for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
+25 -1
View File
@@ -11,8 +11,9 @@ import {
type GameState, type GameState,
type PlayerId, type PlayerId,
} from "../src/game"; } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import type { SustainedEffect } from "../src/game";
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) { export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
return createGame({ playerIds: players, seed, sets: ["basic"] }); return createGame({ playerIds: players, seed, sets: ["basic"] });
@@ -76,3 +77,26 @@ export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; sid
} }
throw new Error("no empty neighbor"); throw new Error("no empty neighbor");
} }
/** Some off-board edge that is a bare stone wall — no warp behind it. */
export function plainRimWall(state: GameState): { cell: Cell; side: Side } {
const board = boardView(state);
for (const k of Object.keys(board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
if (board.cells[cellKey(neighbor({ x, y }, side))]) continue;
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
if (board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
return { cell: { x, y }, side };
}
}
throw new Error("setup: no plain rim wall on this board");
}
/** Rig a duration spell directly, sparing the cast ceremony. */
export function pushSustained(
state: GameState,
fx: Omit<SustainedEffect, "data"> & { data?: SustainedEffect["data"] },
): void {
state.sustained.push({ data: {}, ...fx });
}
@@ -327,7 +327,6 @@ describe("6e card-face corrections", () => {
params: { damage: 4, knockback: 0 }, params: { damage: 4, knockback: 0 },
}); });
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" }); state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
// The fire is a total stop: the attacker's pass settles it.
state = must(state, attacker.id, { type: "pass" }); state = must(state, attacker.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
@@ -343,14 +342,14 @@ describe("6e card-face corrections", () => {
}); });
}); });
describe("relocation past the origin (rules rev 11)", () => { describe("relocation past the origin", () => {
function rev11Game() { function freshGame() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
return state; return state;
} }
it("a sector may land at negative coordinates — the maze renormalizes", () => { it("a sector may land at negative coordinates — the maze renormalizes", () => {
let state = rev11Game(); let state = freshGame();
const me = activePlayer(state); const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!; const other = state.players.find((p) => p.id !== me.id)!;
const idx = state.board.placements.findIndex( const idx = state.board.placements.findIndex(
@@ -384,8 +383,8 @@ describe("relocation past the origin (rules rev 11)", () => {
}); });
}); });
it("older revisions still refuse the negative landing", () => { it("a diagonal landing breaks adjacency and is refused", () => {
let { state } = newGame(); // helper default: rev-ungated (1) let { state } = newGame();
const me = activePlayer(state); const me = activePlayer(state);
const idx = state.board.placements.findIndex( const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, (p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
@@ -401,7 +400,7 @@ describe("relocation past the origin (rules rev 11)", () => {
}); });
it("a moving sector carries its creature, glue, warp tokens, and traps", () => { it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
let state = rev11Game(); let state = freshGame();
const me = activePlayer(state); const me = activePlayer(state);
const idx = state.board.placements.findIndex( const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, (p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
@@ -444,7 +443,7 @@ describe("relocation past the origin (rules rev 11)", () => {
}); });
}); });
describe("junction alterations roll for their sector (rules rev 20)", () => { describe("junction alterations roll for their sector", () => {
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => { it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
let state = toRound2(fresh); let state = toRound2(fresh);
@@ -509,7 +508,7 @@ describe("sight tracing", () => {
}); });
}); });
describe("illusions are tested by choice (rules rev 29)", () => { describe("illusions are tested by choice", () => {
function shimmerRig() { function shimmerRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state); state = toRound2(state);
+3 -18
View File
@@ -2,7 +2,7 @@ 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, SIDES, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, plainRimWall } from "./helpers";
describe("magic wands", () => { describe("magic wands", () => {
it("blaster wand: charges on first use, once per turn, discards when spent", () => { it("blaster wand: charges on first use, once per turn, discards when spent", () => {
@@ -143,27 +143,12 @@ describe("magic wands", () => {
expect(boardView(state).edges[key]).toBe("wall"); expect(boardView(state).edges[key]).toBe("wall");
}); });
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => { it("warp wand bores the rim: a wraparound that closes at turn's end", () => {
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
}); });
const me = activePlayer(state); const me = activePlayer(state);
const view = boardView(state); const rim = plainRimWall(state);
// A plain rim wall: off-board neighbor, no existing warp mouth.
let rim: { cell: Cell; side: Side } | null = null;
outer: for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const dest = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.cells[cellKey(dest)]) continue;
if (view.edges[edgeKey({ x, y }, side)] !== "wall") continue;
if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
rim = { cell: { x, y }, side };
break outer;
}
}
if (!rim) throw new Error("setup: no plain rim wall found");
me.position = { ...rim.cell }; me.position = { ...rim.cell };
const warpsBefore = state.board.warps.length; const warpsBefore = state.board.warps.length;
const wand = giveCard(state, me.id, "warp-wand"); const wand = giveCard(state, me.id, "warp-wand");
+2 -2
View File
@@ -54,8 +54,8 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). /** Rules revision new games are dealt under (stored games keep their own).
* Reset to 1 on 2026-08-21: every earlier room was retired and the engine's * A rules change while games are live must bump this and gate the engine;
* vintage gates collapsed into one canonical ruleset. */ * local hotseat games ride the engine's default and follow in lockstep. */
const RULES_REV = 1; const RULES_REV = 1;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+3 -2
View File
@@ -208,8 +208,9 @@
}); });
/** Legal empty slots the lifted sector may land on — mirroring the /** Legal empty slots the lifted sector may land on — mirroring the
* engine's relocateSector checks: on the 5-grid, non-negative, vacant, * engine's relocateSector checks: on the 5-grid, vacant, and leaving
* and leaving every sector adjacent to at least one other. */ * every sector adjacent to at least one other (negative landings are
* fine; the maze renormalizes). */
const relocateGhosts = $derived.by(() => { const relocateGhosts = $derived.by(() => {
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null; if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
const origins = view.board.placements.map((p) => p.origin); const origins = view.board.placements.map((p) => p.origin);
+2 -2
View File
@@ -162,7 +162,6 @@ 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 "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 "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`; case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll}${e.purpose}.`; case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll}${e.purpose}.`;
@@ -421,7 +420,8 @@ class Net {
// A seat the server answered for but did not list is gone — the // A seat the server answered for but did not list is gone — the
// room was deleted (or the seat's token voided). Quietly drop it // room was deleted (or the seat's token voided). Quietly drop it
// from the ledger rather than showing "unreachable" forever. // from the ledger rather than showing "unreachable" forever.
// (The server checks at most 50 seats per ask; never prune blind.) // (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
// never prune blind past that cap.)
if (this.seats.length <= 50) { if (this.seats.length <= 50) {
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`)); const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`)); const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));