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
// clockwork honors the march to its own gold itself — and when a thief
// carries that gold, DROP OBJECT (an IDIOT aid) shakes it onto the
// floor where it can finally be stood upon.
// IDIOT's curse: the engine never forces the feet, so the clockwork
// honors the march to its own gold itself — and when a thief carries
// that gold, DROP OBJECT (the one attack the curse permits) shakes it
// onto the floor where it can finally be stood upon.
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
const dropper = inHand(view, "drop-object");
@@ -1361,17 +1361,13 @@ export function automatonCommand(
pathToward(view, self.position, enemyCells, { canUnlock });
// A shimmering illusion on the best crossing costs nothing to doubt:
// roll the free test before spending any card on that wall.
{
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
const k = edgeKey(crossing.cell, crossing.side);
if (view.illusionEdges[k] === "untested") {
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
if (doubted && doubted.total < (path?.distance ?? Infinity) &&
view.illusionEdges[edgeKey(doubted.cell, doubted.side)] === "untested") {
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)))) {
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
+90 -132
View File
@@ -21,6 +21,7 @@ import {
cellKey,
edgeKey,
findWarp,
type Warp,
hasLineOfSight,
sightBetween,
neighbor,
@@ -259,7 +260,6 @@ export interface GameState {
wallDamage: Record<string, number>;
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
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:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
@@ -348,8 +348,8 @@ export function losBlockers(state: GameState): Record<string, true> {
return blockers;
}
/** A held-open door is an open doorway to the eye: REMOVE
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
* considered to block L.O.S." speaks of a CLOSED door, and
* the table holds doors open precisely to cast back through them. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board;
@@ -377,7 +377,6 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } {
*/
function illusionBelief(
state: GameState,
events: GameEvent[],
playerId: PlayerId,
key: string,
): "believes" | "seesThrough" {
@@ -387,35 +386,15 @@ function illusionBelief(
return wall.belief[playerId] ?? "believes";
}
/** The board as one player perceives it: believed illusions become walls. */
function perceivedBoard(
state: GameState,
events: GameEvent[],
viewerId: PlayerId,
sightLine?: { from: Cell; to: Cell },
): AssembledBoard {
/** The board as one player perceives it: every illusion this viewer has not
* seen through their own excepted stands as a wall. */
function perceivedBoard(state: GameState, viewerId: PlayerId): AssembledBoard {
const view = boardView(state);
const keys = Object.keys(state.illusionWalls);
if (keys.length === 0) return view;
const edges = { ...view.edges };
for (const key of keys) {
const known = state.illusionWalls[key]!.belief[viewerId];
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
}
if (illusionBelief(state, viewerId, key) === "believes") edges[key] = "wall";
}
return { ...view, edges };
}
@@ -429,9 +408,8 @@ function casterLos(
caster: PlayerState,
from: Cell,
to: Cell,
events: GameEvent[] = [],
): boolean {
const board = openHeldDoors(state, perceivedBoard(state, events, caster.id, { from, to }));
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
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. */
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);
for (const key of Object.keys(view.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
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;
}
}
@@ -594,7 +572,6 @@ export type GameEvent =
| { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
@@ -657,7 +634,7 @@ export type Command =
| { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean }
| { type: "armWard" }
| { type: "wardChoice"; play: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
@@ -831,6 +808,34 @@ function pairedWarpMouth(
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> = {
// --- Attacks: damage ------------------------------------------------------
fireball: {
@@ -946,7 +951,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
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;
ctx.defender.position = to;
ctx.events.push({
@@ -1127,33 +1132,22 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
delete state.createdEdges[pkey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false });
}
// Breaching the maze's outer rim breaks through BOTH sides — the
// default wraparound connects opposite edges, so a new warp opens
//.
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
if (offBoard && !pair) {
const far = oppositePerimeter(view, cell, side);
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 });
// Breaching the maze's outer rim breaks through BOTH sides.
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
? breachRim(state, events, view, cell, side) : null;
if (rim) {
delete state.createdEdges[rim.farKey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: rim.far, wasDoor: false });
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");
}
}
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");
}
}
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 p of state.players) {
@@ -1395,10 +1389,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const { cell, side } = cmd.target;
const view = boardView(state);
// 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
// no matter which rim the wizard steps in from.
// connects it to the opposite edge. The fire takes BOTH mouths, so
// the crossing burns no matter which rim the wizard steps in from.
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)) {
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
// both rims inward (waveFromEdge follows the tunnel).
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)) {
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
// painted on; the far mouth shows nothing (nothing physical exists).
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)) {
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.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
// A rim wall warped open bores a temporary wraparound: the
// far rim wall vanishes with it and a warp runs until turn's end.
// (Pre-40 rim casts opened a dead edge and replay so.)
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
if (offBoard && !findWarp(view, cell, side)) {
const far = oppositePerimeter(view, cell, side);
const farKey = far ? edgeKey(far.cell, far.side) : "";
if (far && (view.edges[farKey] ?? "open") === "wall") {
// A rim wall warped open bores a temporary wraparound: the far rim
// wall vanishes with it and a warp runs until turn's end.
const rim = !view.cells[cellKey(neighbor(cell, side))] && !findWarp(view, cell, side)
? breachRim(state, events, view, cell, side) : null;
if (rim) {
state.tempWarpEdges.push({
key: farKey, prior: state.edgeOverrides[farKey] ?? null,
unwarp: { a: cell, aSide: side, b: far.cell, bSide: far.side },
key: rim.farKey, prior: rim.farPrior,
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: 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 });
}
events.push({ type: "wallWarpedOpen", player: caster.id, edge: rim.far });
}
return null;
},
@@ -1852,16 +1836,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
const view = boardView(state);
// No doors through the maze's rim: a rim door would open onto
// nothing 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") {
// Cells on both sides: a rim door would open onto nothing
// traversable — the wraparound belongs to breaches, not doors.
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
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";
}
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") {
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
// Doors are "small entryways in a stone wall" stone melts as
// stone.
// Doors are "small entryways in a stone wall": stone melts as stone.
const current = view.edges[key];
const meltable = current === "wall" || current === "door";
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:
// the far rim wall washes out too and a new warp opens, so the
// wave below pours through the fresh tunnel onto both rims.
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
if (offBoard && !pair) {
const far = oppositePerimeter(view, cell, side);
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: "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 });
}
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
? breachRim(state, events, view, cell, side) : null;
if (rim) {
delete state.createdEdges[rim.farKey];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: rim.far });
}
// "Wall turns into a WATERWALL with a range and damage of 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 (isLockedInPlace(ctx.state, ctx.defender.id)) return;
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;
const from = ctx.defender.position;
ctx.defender.position = to;
@@ -2217,16 +2187,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
{
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
const queue = order.filter((id) =>
id !== ctx.attacker.id && id !== ctx.defender.id &&
ctx.state.players.find((p) => p.id === id)!.alive);
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
finishChaosIfReady(ctx.state, ctx.events);
return;
}
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
},
},
"illusionary-attack": {
@@ -2263,9 +2229,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
// "Swap any two carried items": every movable object trades — daggers,
// rocks, wands, stones — and a carried TREASURE is an
// item too, named by the "treasure" token (never in older ledgers).
// "Swap any two carried items": every movable object trades —
// daggers, rocks, wands, stones — and a carried TREASURE is an item
// too, named by the "treasure" token.
const tradable = (id: string) => isMovableObject(id);
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
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
// exits & the B tokens on the other" — the two exits you choose become a
// pair, and their former partners pair with each other.
// Earlier revisions swapped the two exits' destinations instead.
resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell;
const bT = cmd.target;
@@ -2475,7 +2440,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const aim = cmd.target.cell;
const view = boardView(state);
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;
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
* source: the force spent reaching them is gone, so a
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far
* edge only 1 and never converts spent force into crush damage.
* source: the force spent reaching them is gone, so a range-2 wave throws
* its adjacent victim 2 spaces but a victim at its far edge only 1 and
* 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;
}
@@ -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 {
// 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.
const warp = !boardView(state).cells[cellKey(neighbor(cell, side))]
? findWarp(boardView(state), cell, side) : undefined;
const warp = rimWarpMouth(boardView(state), cell, side);
const pushes: { start: Cell; dir: Side }[] = warp
? [
{ 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) {
let probe = start;
for (let dist = 0; dist < range; dist++) {
const force = waveForce(state, range, dist);
const force = waveForce(range, dist);
for (const p of state.players) {
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;
for (let dist = 0; dist < range; dist++) {
probe = neighbor(probe, dir);
const force = waveForce(state, range, dist);
const force = waveForce(range, dist);
for (const p of state.players) {
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 (state.squareContents[cellKey(at)]) return "that square is blocked";
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;
spawnCreature(state, events, kind, caster.id, at);
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
* back, and crushed a point per space the maze refuses them. Fire imps
* never reach this water destroys them outright. */
/** The wave carries monsters as it carries wizards: washed back, and
* crushed a point per space the maze refuses them. Fire imps never reach
* this water destroys them outright. */
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
const view = boardView(state);
let moved = 0;
@@ -3170,7 +3134,6 @@ function remapState(
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
}
// A moving sector carries everything standing on it.
{
for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position);
}
@@ -3185,7 +3148,6 @@ function remapState(
if (inSector(w.b)) w.b = mapCell(w.b);
}
}
}
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
function rotateSector(state: GameState, index: number, clockwise: boolean, travelKeys?: Set<string>): void {
@@ -3805,7 +3767,6 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const p = activePlayer(state);
const events: GameEvent[] = [];
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement."
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);
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);
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. */
/** Compat stub for stale clients: the Ward has no arming step. */
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");
}
@@ -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.belief[p.id]) return err("your eyes have already ruled on that wall");
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) &&
!losToEdge(board, p.position, cell, side)) {
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) {
const sighted = mods.aroundCorner
? 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");
}
// 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
// spell, it uses up a turn of that duration" — recipient-counted (FAQ).
// Self-cast durations already burned in beginTurnFor.
{
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) {
@@ -6268,7 +6227,6 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
surviving.push(s);
}
state.sustained = surviving;
}
events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events };
-4
View File
@@ -42,9 +42,6 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null;
turn: TurnState;
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: AssembledBoard;
players: PlayerPublicView[];
@@ -139,7 +136,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
: null,
deckRev: state.config.deckRev ?? 1,
treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length,
discardCount: state.discard.length,
+8 -7
View File
@@ -9,6 +9,7 @@ import {
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
@@ -224,7 +225,7 @@ describe("the clockwork guards gold only within sight", () => {
});
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() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
// 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);
state = r.state;
}
state.sustained.push({
pushSustained(state, {
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
remainingTurns: 9999, data: {},
} as never);
});
return state;
}
@@ -593,10 +594,10 @@ describe("a pact once signed is honored", () => {
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
// 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",
remainingTurns: 1000, data: {},
} as never);
});
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// 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")!;
grim.position = { x: 2, y: 5 };
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",
remainingTurns: 5, data: {},
} as never);
});
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).toBe("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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
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", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
const inGame = (s: typeof two.state) =>
@@ -391,7 +391,7 @@ describe("the ward window and chaos shields", () => {
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
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)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
@@ -452,7 +452,6 @@ describe("the ward window and chaos shields", () => {
expect(state.chaosPending).toBeNull();
expect(defenderHand()).toEqual(kept);
});
});
describe("zero-damage utility attacks", () => {
@@ -493,7 +492,6 @@ describe("zero-damage utility attacks", () => {
target: { kind: "player", playerId: defender },
});
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" });
if (!result.ok) throw new Error(result.error);
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 },
});
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" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
@@ -618,12 +615,12 @@ describe("slime holds spells", () => {
});
describe("answering counteractions (FAQ rulings)", () => {
function rev6() {
function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
}
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
let { state } = rev6();
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
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", () => {
let { state } = rev6();
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
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/);
});
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
let { state } = rev6();
it("ANTI-ANTI does not work against a teleport escape", () => {
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
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", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
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", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
@@ -740,8 +737,8 @@ describe("redirection (rules rev 9)", () => {
});
});
describe("total stops end the exchange (rules rev 10)", () => {
function rev10Game() {
describe("total stops end the exchange", () => {
function faceOffRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(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", () => {
let { state, attacker, defender } = rev10Game();
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
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", () => {
let { state, attacker, defender } = rev10Game();
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
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", () => {
let { state, attacker, defender } = rev10Game();
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
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;
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
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.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", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
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 { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
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). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -139,7 +139,6 @@ describe("monsters", () => {
break;
}
}
// The touch opens the victim's counteraction window; they take it raw.
state = must(state, enemy2.id, { type: "pass" });
const bitten = state.players.find((p) => p.id !== me)!;
expect(bitten.life).toBe(13);
@@ -299,7 +298,7 @@ describe("expansion support cards", () => {
});
describe("counteracting a creature's blow", () => {
function rev4() {
function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
}
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", () => {
let { state } = rev4();
let { state } = freshGame();
const rig = wraithOnVictim(state);
state = rig.state;
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", () => {
let { state } = rev4();
let { state } = freshGame();
const rig = wraithOnVictim(state);
state = rig.state;
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() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
@@ -465,7 +464,7 @@ describe("monsters roll to hit the hidden", () => {
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();
const rngBefore = JSON.stringify(state.rng);
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", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
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", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
@@ -656,7 +655,7 @@ describe("collapsing walls crush monsters too", () => {
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 troll = state.creatures.find((c) => c.id === "tr1");
// 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 };
}
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();
if (!state.stack) return; // a wall between: the touch never opened
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", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "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",
remainingTurns: 5, data: {},
} as never);
});
b.position = { x: 2, y: 5 };
a.position = { x: 0, y: 0 };
state.creatures.push({
@@ -339,7 +339,6 @@ describe("cast modifiers", () => {
target: { kind: "player", playerId: defender },
});
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" });
const d = state.players.find((p) => p.id === defender)!;
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");
}
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();
const pick = giveCard(state, holder, "pick-lock");
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({
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");
expect(open.length).toBeGreaterThanOrEqual(2);
// 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 away = stepTarget(board, d.position, open[1]!);
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", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
@@ -242,23 +241,22 @@ describe("expansion combat cards", () => {
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
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);
expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false);
// 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 r = applyCommand(state, defender, {
type: "cast", instanceId: dr.instanceId,
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
});
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({
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) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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 type { CardInstance } from "../src/cards";
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", () => {
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", () => {
{
let { state } = createGame({
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)!;
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
expect(after.life).toBe(11);
}
});
it("waterwall takes a rim warp — the collapse washes both rims", () => {
{
let { state } = createGame({
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)!;
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
}
});
it("illusion wall hangs on a rim warp mouth", () => {
{
const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
@@ -231,30 +226,14 @@ describe("expansion terrain", () => {
if (r.ok) {
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
}
}
});
it("stone to water breaches the outer rim, opening a warp", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const board = boardView(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");
const rim = plainRimWall(state);
me.position = { ...rim.cell };
const warpsBefore = state.board.warps.length;
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.
expect(state.board.warps.length).toBe(warpsBefore + 2);
}
});
it("no doors through the rim", () => {
{
const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const board = boardView(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");
const rim = plainRimWall(state);
me.position = { ...rim.cell };
const cd = giveCard(state, me.id, "create-door", "CD", 0);
const r = applyCommand(state, me.id, {
@@ -293,11 +258,9 @@ describe("expansion terrain", () => {
target: { kind: "edge", cell: rim.cell, side: rim.side },
});
expect(r.ok).toBe(false);
}
});
it("stone to water melts a door — a small entryway in a stone wall", () => {
{
const { state } = createGame({
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(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", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
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() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
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);
});
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"] });
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);
});
});
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", () => {
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++) {
+25 -1
View File
@@ -11,8 +11,9 @@ import {
type GameState,
type PlayerId,
} 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 { SustainedEffect } from "../src/game";
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
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");
}
/** 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 },
});
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" });
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)", () => {
function rev11Game() {
describe("relocation past the origin", () => {
function freshGame() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
return state;
}
it("a sector may land at negative coordinates — the maze renormalizes", () => {
let state = rev11Game();
let state = freshGame();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
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", () => {
let { state } = newGame(); // helper default: rev-ungated (1)
it("a diagonal landing breaks adjacency and is refused", () => {
let { state } = newGame();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(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", () => {
let state = rev11Game();
let state = freshGame();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(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", () => {
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
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() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
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 { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
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", () => {
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");
});
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({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const view = boardView(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");
const rim = plainRimWall(state);
me.position = { ...rim.cell };
const warpsBefore = state.board.warps.length;
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>();
/** 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
* vintage gates collapsed into one canonical ruleset. */
* A rules change while games are live must bump this and gate the engine;
* local hotseat games ride the engine's default and follow in lockstep. */
const RULES_REV = 1;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+3 -2
View File
@@ -208,8 +208,9 @@
});
/** Legal empty slots the lifted sector may land on — mirroring the
* engine's relocateSector checks: on the 5-grid, non-negative, vacant,
* and leaving every sector adjacent to at least one other. */
* engine's relocateSector checks: on the 5-grid, vacant, and leaving
* every sector adjacent to at least one other (negative landings are
* fine; the maze renormalizes). */
const relocateGhosts = $derived.by(() => {
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
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 "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
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
// room was deleted (or the seat's token voided). Quietly drop it
// 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) {
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}`));