From 8a50ff7f36ed4ecbcf5461842f5677ac415ab45c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 21 Aug 2026 12:45:35 -0400 Subject: [PATCH] The great simplification: every vintage gate collapses, the count restarts at 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 50 rooms were retired (backed up on the droplet and in Spaces), so no ledger needs an old branch to replay: forty revisions of deckRev gates fold into one canonical ruleset — the newest behavior everywhere. The ward's arming, the idiot's steering, the lazy illusion roll, the legacy redirection swap, and the flat wave all leave with their gates; RULES_REV restarts at 1 for whenever the rules fork again. Old-vintage test pins go with them. The opening screen now quietly drops seats whose rooms the server no longer knows, instead of listing them "unreachable" forever. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 19 +- packages/engine/src/game.ts | 511 ++++++------------ packages/engine/src/view.ts | 27 +- packages/engine/test/automaton.test.ts | 33 +- packages/engine/test/board.test.ts | 6 +- packages/engine/test/casting.test.ts | 153 ++---- packages/engine/test/creatures.test.ts | 82 +-- .../engine/test/durations-doors-cards.test.ts | 28 +- packages/engine/test/expansion-combat.test.ts | 44 +- .../engine/test/expansion-terrain.test.ts | 73 +-- packages/engine/test/game.test.ts | 8 +- .../test/sight-illusions-sectors.test.ts | 22 +- .../test/terrain-objects-control.test.ts | 2 +- packages/engine/test/wands.test.ts | 2 +- packages/server/src/rooms.ts | 6 +- packages/web/src/App.svelte | 37 +- packages/web/src/hints.ts | 2 - packages/web/src/local.svelte.ts | 1 - packages/web/src/net.svelte.ts | 12 + 19 files changed, 383 insertions(+), 685 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 1c5a770..0716647 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -784,7 +784,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma } // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after. const vanish = find("invisible"); - if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) { + if (vanish && incoming >= 3 - flinch) { const num = numbersInHand(view)[0]; return { type: "counteract", instanceId: vanish.instanceId, @@ -957,11 +957,6 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm // Three or more wizards: LIFESAVER takes elimination off the table. const lifesaver = inHand(view, "lifesaver"); if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId }; - // The ward guards the gold while its owner is away robbing yours - // (arming retired at rev 31: the window asks in the moment instead). - if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) { - return { type: "armWard", armed: true }; - } const enemyNear = livingEnemies(view).some( (p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3, ); @@ -1323,12 +1318,12 @@ export function automatonCommand( } } - // IDIOT's curse: the maze no longer forces the feet (rev 37), so the + // 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 (the rev-37 carve-out) shakes it onto - // the floor where it can finally be stood upon. + // carries that gold, DROP OBJECT (an IDIOT aid) 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.deckRev >= 37 && view.turn.round > 1 && !view.turn.attackUsed) { + if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) { const dropper = inHand(view, "drop-object"); if (dropper) { const sighted = sightedCellsFor(view); @@ -1365,8 +1360,8 @@ export function automatonCommand( pathToward(view, self.position, objectives, { canUnlock }) ?? 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 (rev 29). - if (view.deckRev >= 29) { + // 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); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 8d5876f..d3642c5 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -191,14 +191,14 @@ export interface CastStack { params: CastParams | null; kind: "spell" | "physical"; counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell; - /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade (rev 27). */ + /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */ cardId?: string }[]; waitingOn: PlayerId; /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */ defenderShielded?: boolean; /** Set when a creature, not a wizard, delivers the attack. */ creatureId?: string; - /** A FULL REFLECTION turned the spell around (rules rev 25): this stack + /** A FULL REFLECTION turned the spell around: this stack * is the returned blow, damage and duration already settled — the new * defender (the original caster) answers with counteractions of their * own. Another full reflection here turns it around again. */ @@ -259,9 +259,8 @@ export interface GameState { wallDamage: Record; /** Spells stuck in slime, waiting for the next visitor (by cell key). */ slimeTraps: Record; - /** Players whose WARD is set to spring (rules rev 3+; their secret). */ - wardArmed: PlayerId[]; - /** A treasure was just grabbed and its owner holds WARD (rules rev 31): + /** 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; /** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */ @@ -349,11 +348,11 @@ export function losBlockers(state: GameState): Record { return blockers; } -/** A held-open door is an open doorway to the eye (rules rev 15): REMOVE +/** 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.config.deckRev ?? 1) < 15 || state.heldDoors.length === 0) return board; + if (state.heldDoors.length === 0) return board; const edges = { ...board.edges }; for (const h of state.heldDoors) delete edges[h.key]; return { ...board, edges }; @@ -372,10 +371,9 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } { } /** - * What does this player believe about an illusion wall? Before rules rev 29 - * the 50% chance rolled lazily the first time it mattered; from rev 29 the - * test is an explicit act (the testIllusion command) and an untested wall - * simply IS a wall to this player until they choose to doubt it. + * What does this player believe about an illusion wall? The test is an + * explicit act (the testIllusion command); an untested wall simply IS a + * wall to this player until they choose to doubt it. */ function illusionBelief( state: GameState, @@ -385,15 +383,8 @@ function illusionBelief( ): "believes" | "seesThrough" { const wall = state.illusionWalls[key]!; if (wall.createdBy === playerId) return "seesThrough"; - const known = wall.belief[playerId]; - if (known) return known; - if ((state.config.deckRev ?? 1) >= 29) return "believes"; // untested blocks; no roll - const [roll, rngNext] = rollDie(state.rng); - state.rng = rngNext; - const result = roll <= 2 ? "seesThrough" : "believes"; - wall.belief[playerId] = result; - events.push({ type: "illusionTested", player: playerId, edge: key, result }); - return result; + // An untested illusion blocks; testing it is its own free action. + return wall.belief[playerId] ?? "believes"; } /** The board as one player perceives it: believed illusions become walls. */ @@ -786,7 +777,6 @@ function rollJunctionAlterations( origin: Cell, ): Set { const travel = new Set(); - if ((state.config.deckRev ?? 1) < 20) return travel; const inSector = (c: Cell) => c.x >= origin.x && c.x < origin.x + 5 && c.y >= origin.y && c.y < origin.y + 5; const altered = new Set([ @@ -851,12 +841,9 @@ const CARD_EFFECTS: Record if (ctx.damageDealt <= 0) return; // "Destroys all magical stones an opponent is CARRYING": a stone // still hidden in the hand is a secret card, not a carried stone — - // only displayed stones burn (rules rev 33; earlier games burned - // the hand's hidden stones too and replay so). - const rev33 = (ctx.state.config.deckRev ?? 1) >= 33; + // only displayed stones burn. const stones = ctx.defender.hand.filter((c) => - isMagicStone(c.cardId) && - (!rev33 || ctx.defender.displayed.includes(c.instanceId))); + isMagicStone(c.cardId) && ctx.defender.displayed.includes(c.instanceId)); if (stones.length === 0) return; ctx.defender.hand = ctx.defender.hand.filter( (c) => !stones.some((s) => s.instanceId === c.instanceId), @@ -878,10 +865,9 @@ const CARD_EFFECTS: Record ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); // Stunned mid-own-turn (the bolt came back): FAQ — "If this gets FULLY // REFLECTED, you lose the rest of your turn and can't draw cards (in - // addition to damage)." Rules rev 28; older games played on and replay so. + // addition to damage)." const st = ctx.state; - if ((st.config.deckRev ?? 1) >= 28 && - st.players[st.turn.activeIndex]!.id === ctx.defender.id) { + if (st.players[st.turn.activeIndex]!.id === ctx.defender.id) { st.turn.actionsEnded = true; st.turn.drawForbidden = true; } @@ -940,11 +926,7 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; const n = ctx.stack.numberValue ?? 1; - if ((ctx.state.config.deckRev ?? 1) >= 36) { - goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); - } else { - knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); - } + goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); ctx.defender.lostTurns++; ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); }, @@ -1147,9 +1129,9 @@ const CARD_EFFECTS: Record } // Breaching the maze's outer rim breaks through BOTH sides — the // default wraparound connects opposite edges, so a new warp opens - // (rules rev 19; sector moves recompute openings and forget it). + //. const offBoard = !view.cells[cellKey(neighbor(cell, side))]; - if ((state.config.deckRev ?? 1) >= 19 && offBoard && !pair) { + 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") { @@ -1161,11 +1143,9 @@ const CARD_EFFECTS: Record applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical"); } } - if ((state.config.deckRev ?? 1) >= 21) { - for (const cr of [...state.creatures]) { - if (cellKey(cr.position) === cellKey(far.cell)) { - damageCreature(state, events, cr, 4, "collapsing wall"); - } + for (const cr of [...state.creatures]) { + if (cellKey(cr.position) === cellKey(far.cell)) { + damageCreature(state, events, cr, 4, "collapsing wall"); } } state.board.warps.push( @@ -1182,12 +1162,10 @@ const CARD_EFFECTS: Record } } // "Anyone in either square" includes monsters — the expansion sheet - // says so in as many words (rules rev 21). - if ((state.config.deckRev ?? 1) >= 21) { - for (const cr of [...state.creatures]) { - if (cellKey(cr.position) === cellKey(c)) { - damageCreature(state, events, cr, 4, "collapsing wall"); - } + // says so in as many words. + for (const cr of [...state.creatures]) { + if (cellKey(cr.position) === cellKey(c)) { + damageCreature(state, events, cr, 4, "collapsing wall"); } } } @@ -1257,8 +1235,8 @@ const CARD_EFFECTS: Record if (wallIgnoringDistance(view, caster.position, to) > 4) { return "teleport reaches at most four spaces"; } - // A teleport is a willing move: FEAR's bubble refuses it (rev 32). - if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, caster.id, caster.position, to)) { + // A teleport is a willing move: FEAR's bubble refuses it. + if (fearRepels(state, caster.id, caster.position, to)) { return "an unnatural dread keeps you away"; } const from = caster.position; @@ -1417,12 +1395,10 @@ const CARD_EFFECTS: Record 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 (rev 39; older ledgers refused it - // and replay so). The fire takes BOTH mouths, so the crossing burns + // 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 && (state.config.deckRev ?? 1) >= 39 - ? findWarp(view, cell, side) : undefined; + const warp = offBoard ? findWarp(view, cell, side) : undefined; if (!view.cells[cellKey(cell)] || (offBoard && !warp)) { return "the fire must span a corridor between two spaces"; } @@ -1461,10 +1437,10 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge"; const { cell, side } = cmd.target; const view = boardView(state); - // A rim warp mouth is a corridor too (rev 40): the collapse washes + // 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 && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side); + const warpMouth = offBoard && findWarp(view, cell, side); if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { return "the wave must span a corridor between two spaces"; } @@ -1624,11 +1600,11 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge"; const { cell, side } = cmd.target; const view = boardView(state); - // A rim warp mouth takes the scrim too (rev 40) — the bluff of + // A rim warp mouth takes the scrim too — the bluff of // 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 && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side); + const warpMouth = offBoard && findWarp(view, cell, side); if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { return "the illusion must span two spaces on the board"; } @@ -1802,11 +1778,11 @@ const CARD_EFFECTS: Record 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 (rev 40): the + // 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 ((state.config.deckRev ?? 1) >= 40 && offBoard && !findWarp(view, 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") { @@ -1876,9 +1852,9 @@ const CARD_EFFECTS: Record const { cell, side } = cmd.target; const key = edgeKey(cell, side); const view = boardView(state); - // No doors through the maze's rim (rev 40): a rim door would open onto + // No doors through the maze's rim: a rim door would open onto // nothing traversable — the wraparound belongs to breaches, not doors. - if ((state.config.deckRev ?? 1) >= 40 && !view.cells[cellKey(neighbor(cell, side))]) { + if (!view.cells[cellKey(neighbor(cell, side))]) { return "the door must stand between two spaces"; } if ((view.edges[key] ?? "open") === "open") { @@ -1994,43 +1970,40 @@ const CARD_EFFECTS: Record const { cell, side } = cmd.target; const key = edgeKey(cell, side); // Doors are "small entryways in a stone wall" — stone melts as - // stone (rev 38; older ledgers had refused doors and replay so). + // stone. const current = view.edges[key]; - const meltable = current === "wall" || - ((state.config.deckRev ?? 1) >= 38 && current === "door"); + const meltable = current === "wall" || current === "door"; if (!meltable) return "that is not a stone wall"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "open"; delete state.doorStates[key]; delete state.createdEdges[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } }); - if ((state.config.deckRev ?? 1) >= 40) { - // A brick over a warp mouth melts at BOTH ends — the tunnel is one - // wall line (rev 40, mirroring DESTROY WALL's pair rule). - const pair = pairedWarpMouth(view, cell, side); - const pairKey = pair ? edgeKey(pair.cell, pair.side) : ""; - if (pair && state.createdEdges[pairKey]) { - state.edgeOverrides[pairKey] = "open"; - delete state.createdEdges[pairKey]; - events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: pair }); - } - // 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 }); - } + // A brick over a warp mouth melts at BOTH ends — the tunnel is one + // wall line. + const pair = pairedWarpMouth(view, cell, side); + const pairKey = pair ? edgeKey(pair.cell, pair.side) : ""; + if (pair && state.createdEdges[pairKey]) { + state.edgeOverrides[pairKey] = "open"; + delete state.createdEdges[pairKey]; + events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: pair }); + } + // 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 }); } } // "Wall turns into a WATERWALL with a range and damage of 2." @@ -2239,13 +2212,12 @@ const CARD_EFFECTS: Record kind: "attack", baseDamage: () => 0, // Everyone's hands into one pile, shuffled, dealt back in equal counts. - // "FULL SHIELD removes a player from participation": under rules rev 3 - // every bystander gets a shield window before the pile forms; earlier - // revisions scramble at once so stored games replay unchanged. + // "FULL SHIELD removes a player from participation": every bystander + // gets a shield window before the pile forms. onResolved: (ctx) => { if (ctx.fullyStopped) return; const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : []; - if ((ctx.state.config.deckRev ?? 1) >= 3) { + { const order = turnOrderFrom(ctx.state, ctx.attacker.id); const queue = order.filter((id) => id !== ctx.attacker.id && id !== ctx.defender.id && @@ -2292,13 +2264,9 @@ const CARD_EFFECTS: Record 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 (rules rev 26; earlier games matched only - // object-typed cards and replay so) — and a carried TREASURE is an + // rocks, wands, stones — and a carried TREASURE is an // item too, named by the "treasure" token (never in older ledgers). - const tradable = (id: string) => - (ctx.state.config.deckRev ?? 1) >= 26 - ? isMovableObject(id) - : cardDef(id).cardType === "object"; + const tradable = (id: string) => isMovableObject(id); type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" }; const side = (p: PlayerState, id: string): TradeSide | null => { if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null; @@ -2418,10 +2386,8 @@ const CARD_EFFECTS: Record if (!hasTreasureOut) return; // "ends if both treasures are being carried" // FAQ: "If you happen to already be carrying one of your treasures // when you are hit with this spell, it has no effect on you." - if ((ctx.state.config.deckRev ?? 1) >= 23) { - const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId); - if (carried?.owner === ctx.defender.id) return; - } + const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId); + if (carried?.owner === ctx.defender.id) return; attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); }, }, @@ -2463,7 +2429,7 @@ const CARD_EFFECTS: Record kind: "neutral", // "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 (rules rev 9), 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) => { const a = cmd.params?.cell; @@ -2473,27 +2439,15 @@ const CARD_EFFECTS: Record const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a)); const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b)); if (!wa || !wb || wa === wb) return "pick two different outer exits"; - if ((state.config.deckRev ?? 1) >= 9) { - if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect"; - const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell)); - const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell)); - // The chosen exits join (A tokens); their old partners join (B tokens). - wa.to = { cell: { ...wb.from.cell }, side: wb.from.side }; - wb.to = { cell: { ...wa.from.cell }, side: wa.from.side }; - if (pa && pb) { - pa.to = { cell: { ...pb.from.cell }, side: pb.from.side }; - pb.to = { cell: { ...pa.from.cell }, side: pa.from.side }; - } - } else { - // Legacy: swap destinations and fix the reciprocal warps to match. - const destA = { ...wa.to }; - const destB = { ...wb.to }; - wa.to = destB; - wb.to = destA; - for (const w of state.board.warps) { - if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side }; - if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side }; - } + if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect"; + const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell)); + const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell)); + // The chosen exits join (A tokens); their old partners join (B tokens). + wa.to = { cell: { ...wb.from.cell }, side: wb.from.side }; + wb.to = { cell: { ...wa.from.cell }, side: wa.from.side }; + if (pa && pb) { + pa.to = { cell: { ...pb.from.cell }, side: pb.from.side }; + pb.to = { cell: { ...pa.from.cell }, side: pa.from.side }; } events.push({ type: "exitsRedirected", caster: caster.id }); return null; @@ -2667,21 +2621,19 @@ 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 (rules rev 24), so a + * 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 { - return (state.config.deckRev ?? 1) >= 24 ? range - dist : range; + return range - dist; } /** A collapsing waterwall wave from an edge: wash players back `range`. */ 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 (rev 40; older ledgers' rim waves pushed into the void - // and replay so). A plain edge washes its two adjacent cells apart. - const warp = (state.config.deckRev ?? 1) >= 40 && - !boardView(state).cells[cellKey(neighbor(cell, side))] + // 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 pushes: { start: Cell; dir: Side }[] = warp ? [ @@ -2703,7 +2655,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S if (cellKey(c.position) !== cellKey(probe)) continue; if (c.kind === "fire-imp") { destroyCreature(state, events, c, reason); - } else if ((state.config.deckRev ?? 1) >= 17) { + } else { washBackCreature(state, events, c, dir, force); } } @@ -2731,7 +2683,7 @@ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range if (cellKey(c.position) !== cellKey(probe)) continue; if (c.kind === "fire-imp") { destroyCreature(state, events, c, "rushing water"); - } else if ((state.config.deckRev ?? 1) >= 17) { + } else { washBackCreature(state, events, c, dir, force); } } @@ -2804,9 +2756,8 @@ function emptySquareTarget( if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there"; if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there"; if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there"; - // "You can't create an object on a DIMENSIONAL WARP" (rules rev 8). - if ((state.config.deckRev ?? 1) >= 8 && - state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) { + // "You can't create an object on a DIMENSIONAL WARP". + if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) { return "the warp shimmers there — nothing can form on it"; } if (!gameLos(state, caster.position, cell)) return "no line of sight"; @@ -2833,7 +2784,7 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S } } -/** The wave carries monsters as it carries wizards (rules rev 17): washed +/** 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 { @@ -2892,9 +2843,8 @@ function spawnCreature( movementUsed: 0, // "It may move on that turn." (Exp1 sheet) // "Cannot attack the turn they are created" is justCreated's job; the // democratic monster's attackUsed budgets its ROUND, so spending it at - // birth would mute a mid-round summon until the round rolled over - // (rules rev 18). - attackUsed: !(kind === "democratic-monster" && (state.config.deckRev ?? 1) >= 18), + // birth would mute a mid-round summon until the round rolled over. + attackUsed: kind !== "democratic-monster", justCreated: true, wallPassesPerTurn: stats.wallPasses, wallPassUsed: 0, @@ -2983,15 +2933,14 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C } else { const content = state.squareContents[cellKey(target.to)]; if (content?.kind === "stone") return err("that square is solid stone"); - if ((state.config.deckRev ?? 1) >= 5 && - state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) && + if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) && sustainedOn(state, o.id, "big-man").length > 0)) { return err("a giant fills that corridor"); } creature.position = target.to; } - // FEAR holds monsters off too (rules rev 32): "no player or monster". - if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, null, from, creature.position)) { + // FEAR holds monsters off too: "no player or monster". + if (fearRepels(state, null, from, creature.position)) { creature.position = from; if (creature.wallPassUsed > 0) creature.wallPassUsed--; return err("an unnatural dread stops the beast"); @@ -3000,37 +2949,22 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); // Touch effects on entering a player's square. - const rev4 = (state.config.deckRev ?? 1) >= 4; for (const p of state.players) { if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator if (creature.kind === "wraith" && !creature.attackUsed) { creature.attackUsed = true; events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - if (rev4) { - // The victim may counteract ("REFLECTIONs used on the wraith's touch - // will damage the wraith" — the card assumes exactly this window). - openCreatureStack(state, creature, p, 2, "wraith"); - return { ok: true, state, events }; - } - applyDamage(state, events, p, 2, "wraith's touch", null); - if (p.alive && p.hand.length > 0) { - const [idx, rngNext] = nextInt(state.rng, p.hand.length); - state.rng = rngNext; - const [card] = p.hand.splice(idx, 1); - p.displayed = p.displayed.filter((id) => id !== card!.instanceId); - state.discard.push(card!); - events.push({ type: "cardsDiscarded", player: p.id, cards: [card!] }); - } + // The victim may counteract ("REFLECTIONs used on the wraith's touch + // will damage the wraith" — the card assumes exactly this window). + openCreatureStack(state, creature, p, 2, "wraith"); + return { ok: true, state, events }; } if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { creature.attackUsed = true; // "may attack only one player per round of turns" events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - if (rev4) { - openCreatureStack(state, creature, p, 2, "claw"); - return { ok: true, state, events }; - } - applyDamage(state, events, p, 2, "clawing monster", null); + openCreatureStack(state, creature, p, 2, "claw"); + return { ok: true, state, events }; } } checkVictory(state, events); @@ -3063,7 +2997,7 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt }; } -/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */ +/** A creature's blow opens a counteraction window like any attack. */ function openCreatureStack( state: GameState, creature: CreatureState, @@ -3128,11 +3062,8 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string) } events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: targetId, dieRoll: roll }); if (targetPlayer) { - if ((state.config.deckRev ?? 1) >= 4) { - openCreatureStack(state, creature, targetPlayer, amount); - return { ok: true, state, events }; - } - applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical"); + openCreatureStack(state, creature, targetPlayer, amount); + return { ok: true, state, events }; } else { damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`); } @@ -3238,10 +3169,8 @@ function remapState( for (const t of state.treasures) { if (t.position && inSector(t.position)) t.position = mapCell(t.position); } - // A moving sector carries everything standing on it. Earlier revisions left - // creatures, traps, glue, safes, and warp tokens at their old coordinates; - // their stored games must replay with that flaw intact (rules rev 11). - if ((state.config.deckRev ?? 1) >= 11) { + // A moving sector carries everything standing on it. + { for (const c of state.creatures) { if (inSector(c.position)) c.position = mapCell(c.position); } @@ -3289,11 +3218,6 @@ function relocateSector(state: GameState, index: number, dest: Cell, travelKeys? const placements = state.board.placements; const current = placements[index]!.origin; if (dest.x === current.x && dest.y === current.y) return "the sector is already there"; - // The grid has no wall at zero: a sector may land beyond the old origin and - // the whole maze renormalizes back into positive coordinates (rules rev 11). - if ((state.config.deckRev ?? 1) < 11 && (dest.x < 0 || dest.y < 0)) { - return "the sector cannot go there"; - } for (let i = 0; i < placements.length; i++) { if (i === index) continue; const o = placements[i]!.origin; @@ -3317,13 +3241,11 @@ function relocateSector(state: GameState, index: number, dest: Cell, travelKeys? const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p)); // Zero-anchor the maze after the move: negative landings shift back into // positive coordinates, and vacating the origin corner pulls the maze snug - // instead of leaving a blank band (rules rev 11; older games never shift). - const shift = (state.config.deckRev ?? 1) >= 11 - ? { - x: -Math.min(...newPlacements.map((p) => p.origin.x)), - y: -Math.min(...newPlacements.map((p) => p.origin.y)), - } - : { x: 0, y: 0 }; + // instead of leaving a blank band. + const shift = { + x: -Math.min(...newPlacements.map((p) => p.origin.x)), + y: -Math.min(...newPlacements.map((p) => p.origin.y)), + }; const shifted = shift.x || shift.y ? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } })) : newPlacements; @@ -3534,7 +3456,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game ); let fullDeck = buildDeck(config.sets); - if ((config.deckRev ?? 1) >= 2 && n === 2) { + if (n === 2) { fullDeck = fullDeck.filter((c) => c.cardId !== "lifesaver"); } const [deckShuffled, rng3] = shuffle(rng, fullDeck); @@ -3580,7 +3502,6 @@ export function createGame(config: GameConfig): { state: GameState; events: Game edgeOverrides: {}, wallDamage: {}, slimeTraps: {}, - wardArmed: [], wardPending: null, chaosPending: null, doorStates: {}, @@ -3778,7 +3699,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "punch": return doPunch(state, command.targetId); case "punchWall": return doPunchWall(state, command.cell, command.side); case "testIllusion": return doTestIllusion(state, command.cell, command.side); - case "armWard": return doArmWard(state, command.armed); + case "armWard": return doArmWard(); case "wardChoice": return err("no grab is hanging on your Ward"); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); @@ -3850,23 +3771,16 @@ export function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number { /** * FEAR: "no player or monster will move to within 3 spaces of you." True * for every WILLING move — walking, warp steps, self-teleports, commanded - * monsters (rules rev 32 widened it beyond walking wizards; forced movement - * such as knockback or a wave is not willing and passes). + * monsters. Forced movement such as knockback or a wave is not willing + * and passes. */ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean { - // The wrap joined the measure at rev 32; older ledgers were recorded - // under the flat diamond and replay so. - const wraps = (state.config.deckRev ?? 1) >= 32; - const board = wraps ? boardView(state) : null; + const board = boardView(state); for (const other of state.players) { if (!other.alive || other.id === moverId) continue; if (sustainedOn(state, other.id, "fear").length === 0) continue; - const d = board - ? dreadDistance(board, other.position, to) - : Math.abs(other.position.x - to.x) + Math.abs(other.position.y - to.y); - const dBefore = board - ? dreadDistance(board, other.position, from) - : Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y); + const d = dreadDistance(board, other.position, to); + const dBefore = dreadDistance(board, other.position, from); if (d <= 3 && d < dBefore) return true; } return false; @@ -3891,15 +3805,6 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { const p = activePlayer(state); const events: GameEvent[] = []; - // IDIOT: every move heads for the nearest of their own treasures. - // Rev 37 retires the steering: a greedy per-step recalculation ping-pongs - // between routes that tie at a corner and can wall the victim off from - // the cure entirely. The march is the player's to honor (like FEAR's - // duty); the automatons steer themselves. Older ledgers replay steered. - if ((state.config.deckRev ?? 1) < 37 && sustainedOn(state, p.id, "idiot").length > 0) { - const steered = idiotSteer(state, p); - if (steered) direction = steered; - } // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // move ... bumping into a wall counts as one space of movement." @@ -3911,8 +3816,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { { const key = edgeKey(p.position, direction); const shimmer = state.illusionWalls[key]; - if (shimmer && (state.config.deckRev ?? 1) >= 29 && - shimmer.createdBy !== p.id && !shimmer.belief[p.id]) { + if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) { if (isBlinded(state, p)) return blindBump(state, events, p, direction); return err("the wall shimmers oddly — test your eyes before you can pass"); } @@ -3924,11 +3828,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { const view = boardView(state); // BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or - // KILLER OOZE" (rules rev 5). The giant strides the hazard square without + // KILLER OOZE". The giant strides the hazard square without // entering it — one point charged here, then the normal step below lands // him beyond it with every usual arrival effect (and the second point). if (over) { - const big = (state.config.deckRev ?? 1) >= 5 && sustainedOn(state, p.id, "big-man").length > 0; + const big = sustainedOn(state, p.id, "big-man").length > 0; if (!big) return err("only a giant steps over hazards"); if (isBlinded(state, p)) return err("you cannot leap what you cannot see"); if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement"); @@ -3952,8 +3856,8 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); if (!view.cells[cellKey(dest)]) { - // A fire on a warp mouth (rev 39 casts allow it): the wraparound - // corridor still runs — through flame. + // A fire on a warp mouth: the wraparound corridor still runs — + // through flame. const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined; if (!warp) { if (isBlinded(state, p)) return blindBump(state, events, p, direction); @@ -4002,10 +3906,9 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { } } // BIG MAN pushes: "You can push monsters or other players (in an adjacent - // square) down the corridor ahead of you as you move" (rules rev 5). No + // square) down the corridor ahead of you as you move". No // room to shove them onward means no way forward for the giant either. - if ((state.config.deckRev ?? 1) >= 5 && via === "step" && - sustainedOn(state, p.id, "big-man").length > 0) { + if (via === "step" && sustainedOn(state, p.id, "big-man").length > 0) { const here = cellKey(p.position); const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here); const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here); @@ -4200,7 +4103,7 @@ function doWarpStep(prev: GameState): CommandResult { const dest = cellKey(pair.a) === here ? pair.b : pair.a; if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); const from = p.position; - if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, p.id, from, dest)) { + if (fearRepels(state, p.id, from, dest)) { return err("an unnatural dread keeps you away"); } p.position = { ...dest }; @@ -4290,10 +4193,10 @@ function castingBlocked( // needed)" — and per the FAQ, spells that aid the march to the treasure. if (sustainedOn(state, playerId, "idiot").length > 0 && !opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) { - // Rev 37: DROP OBJECT that shakes YOUR OWN treasure out of a thief's - // arms serves the march — while carried, that treasure cannot even be + // DROP OBJECT that shakes YOUR OWN treasure out of a thief's arms + // serves the march — while carried, that treasure cannot even be // stood upon, so without this the curse can be unsatisfiable. - const freesOwnGold = (state.config.deckRev ?? 1) >= 37 && + const freesOwnGold = opts?.cardId === "drop-object" && opts.pickedCardId === "treasure" && opts.targetPlayerId != null && state.treasures.some((t) => t.owner === playerId && t.carriedBy === opts.targetPlayerId); @@ -4312,30 +4215,12 @@ function castingBlocked( * instant cure. */ function idiotBlocked(state: GameState, playerId: PlayerId): string | null { - if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) { + if (sustainedOn(state, playerId, "idiot").length > 0) { return "What am I doing here...? (you can do nothing but head for your treasure)"; } return null; } -/** IDIOT: the victim's moves are steered toward their nearest own treasure. */ -function idiotSteer(state: GameState, p: PlayerState): Side | null { - const targets = state.treasures.filter((t) => t.owner === p.id && t.position); - if (targets.length === 0) return null; - const view = boardView(state); - let best: { side: Side; dist: number } | null = null; - for (const side of SIDES) { - const step = stepTarget(view, p.position, side); - if (step.kind === "blocked") continue; - if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; - for (const t of targets) { - const d = walkingDistance(state, step.to, t.position!); - if (best === null || d < best.dist) best = { side, dist: d }; - } - } - return best?.side ?? null; -} - /** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null { if (sustainedOn(state, target.id, "big-man").length > 0 && @@ -4415,23 +4300,10 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult { } /** Arm (or stand down) the WARD trap on your treasures. Your secret. */ -function doArmWard(prev: GameState, armed: boolean): CommandResult { - // Rev 31 reads the card literally: the Ward is played in the moment of - // the grab, never set ahead. Older games keep their arming and replay so. - if ((prev.config.deckRev ?? 1) >= 31) { - return err("the Ward is played in the moment — you will be asked when your treasure is grabbed"); - } - const state = clone(prev); - const p = activePlayer(state); - if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD"); - const already = state.wardArmed.includes(p.id); - if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set"); - state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id); - return { - ok: true, - state, - events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }], - }; +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"); } /** Player ids in seat order, starting after `fromId`. */ @@ -4466,13 +4338,12 @@ function finishChaosIfReady(state: GameState, events: GameEvent[]): void { } /** - * TEST AN ILLUSION (rules rev 29): a free act on your turn. Standing beside + * TEST AN ILLUSION: a free act on your turn. Standing beside * the shimmer or seeing it, you roll your 50% doubt — the wall then either * hardens (for you) or dissolves (for you). Nobody else's eyes are moved by * your verdict, or by anyone strolling through it. */ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult { - if ((prev.config.deckRev ?? 1) < 29) return err("illusions test themselves in this vintage of the rules"); const state = clone(prev); const p = activePlayer(state); const key = edgeKey(cell, side); @@ -5094,10 +4965,9 @@ function doSetAmbush(prev: GameState, cmd: Extract 1) return err("one number card per action"); - // Cell-needing spells commit their destination when the trap is laid - // (rules rev 35; older games armed them blind and their springs fizzle). + // Cell-needing spells commit their destination when the trap is laid. const needsCell = spell.cardId === "teleport-opponent" || spell.cardId === "mental-force"; - if (needsCell && (prev.config.deckRev ?? 1) >= 35) { + if (needsCell) { if (!cmd.cell) return err("choose where the ambush will send them"); const v = boardView(state); if (!v.cells[cellKey(cmd.cell)]) return err("that destination is off the board"); @@ -5219,13 +5089,12 @@ function checkAmbushes( /** Damage dealt inside an attack's own resolution hook still honors * EMPATHY — "Any attack done in ANY form against you acts against both you - * and the caster" (rules rev 16; earlier ledgers resolved these blows - * unmirrored and replay so). */ + * and the caster". */ function resolvedBlow( ctx: ResolutionContext, dmg: number, source: string, kind?: "physical", ): void { applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind); - if ((ctx.state.config.deckRev ?? 1) >= 16 && dmg > 0 && ctx.attacker.alive && + if (dmg > 0 && ctx.attacker.alive && sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) { applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind); } @@ -5259,8 +5128,8 @@ function doCounteract( const castBlock = castingBlocked(state, playerId, { counteraction: true }); if (castBlock) return err(castBlock); - // "REFLECTIONS have no effect" against CHAOS (rules rev 3). - if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 && + // "REFLECTIONS have no effect" against CHAOS. + if (stack.attackCard?.cardId === "chaos" && (card.cardId === "reflection" || card.cardId === "full-reflection")) { return err("REFLECTIONS have no effect against CHAOS"); } @@ -5322,9 +5191,6 @@ function doCounteract( // The spell truly goes up — duration from an attached NUMBER card — and // the attacker's 1-in-4 hit roll happens at resolution. if (card.cardId === "invisible") { - if (stack.creatureId && (state.config.deckRev ?? 1) < 13) { - return err("in this game's revision, creatures are not fooled by the vanishing"); - } const duration = counterDuration(state, player, numberInstanceIds); takeFromHand(player, instanceId); state.discard.push(card); @@ -5440,11 +5306,9 @@ function doCounteract( if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction"); if (!targetCounter) return err("no counteraction to nullify"); // "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE" - // (the card face). Teleport is rev-6-gated — earlier games replay their - // old chains; invisible needs no gate, since no earlier engine ever - // accepted it as a counteraction for a ledger to contain. + // (the card face). if (targetCounter.card.cardId === "invisible" || - ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) { + targetCounter.card.cardId === "teleport") { return err("ANTI-ANTI does not work against escape"); } takeFromHand(player, instanceId); @@ -5478,23 +5342,18 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult { const events: GameEvent[] = []; // A self-stack (a democratic monster touching its own creator) has one // party wearing both hats: their pass RESOLVES — bouncing it "to the - // defender" would hand the exchange back to themselves forever - // (rules rev 22; older ledgers hold those no-op bounces and replay so). - const selfStack = - stack.attackerId === stack.defenderId && (state.config.deckRev ?? 1) >= 22; + // defender" would hand the exchange back to themselves forever. + const selfStack = stack.attackerId === stack.defenderId; if (playerId === stack.attackerId && !selfStack) { // The attacker declining to answer bounces the exchange back so the // defender may stack further counters — but not past a total stop: // re-prompting then reads as "your shield failed" and goads the table - // into burning cards against a spell that is already dead (rules rev 10). - const rev = state.config.deckRev ?? 1; - const totalStop = rev >= 10 && + // into burning cards against a spell that is already dead. + const totalStop = stack.counters.some((c) => !c.nullified && (TOTAL_STOP_COUNTERS.has(c.card.cardId) || - // Wall-of-fire counters pre-date the total-stop rule; their stored - // exchanges bounced, and replay so (rules rev 12). - (rev >= 12 && c.card.cardId === "wall-of-fire")) && + c.card.cardId === "wall-of-fire") && (stack.kind === "spell" || c.card.cardId === "teleport")); if (!totalStop) { stack.waitingOn = stack.defenderId; @@ -5517,10 +5376,10 @@ function resolveStack(state: GameState, events: GameEvent[]): void { // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and // SHRINK ("Reduces opponent's chance to hit you by 50% in any attack"). - // Creature blows roll the same dice from rev 13 — the cards carve out no - // exception for monsters; earlier games skipped the rolls and replay so. - // A reflected spell returns to its sender unerringly — no fresh hit rolls. - const missRolls = (!stack.creatureId || (state.config.deckRev ?? 1) >= 13) && !stack.reflectedBase; + // Creature blows roll the same dice — the cards carve out no exception + // for monsters. A reflected spell returns to its sender unerringly — no + // fresh hit rolls. + const missRolls = !stack.reflectedBase; if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) { const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them"); if (roll !== 1) { @@ -5548,13 +5407,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void { base += 2; } // STONE DEAD: number x the stones the defender carries — and "carrying" - // means displayed, as with fireball's burning (rules rev 34): a hidden - // stone is a secret card, and counting it would broadcast the secret in - // the damage total. Earlier games counted the whole hand and replay so. + // means displayed, as with fireball's burning: a hidden stone is a + // secret card, and counting it would broadcast the secret in the + // damage total. if (attackId === "stone-dead") { - const inPlay = (state.config.deckRev ?? 1) >= 34 - ? defender.hand.filter((c) => isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId)) - : defender.hand.filter((c) => isMagicStone(c.cardId)); + const inPlay = defender.hand.filter((c) => + isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId)); base = (stack.numberValue ?? 1) * inPlay.length; } base *= stack.amplifyFactor; @@ -5597,7 +5455,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { reversed: false, kind: stack.kind, }; - if (attackId === "chaos" && (state.config.deckRev ?? 1) >= 3) { + if (attackId === "chaos") { const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield"); if (shielded) { stack.defenderShielded = true; @@ -5658,9 +5516,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void { : undefined; if (pipe.redirected) { // SWAP MEET turned: "FULL REFLECTION lets the other player decide which - // objects, if any, will be swapped" (rules rev 27). The reflector's + // objects, if any, will be swapped". The reflector's // choice rode their counter — absent, or "none", nothing trades. - if (attackId === "swap-meet" && (state.config.deckRev ?? 1) >= 27) { + if (attackId === "swap-meet") { const fr = stack.counters.find((c) => !c.nullified && c.card.cardId === "full-reflection"); const chosen = fr?.cardId; if (chosen && chosen !== "none" && effect?.onResolved && !attackingCreature) { @@ -5684,9 +5542,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void { return; } // The returned spell is a fresh attack on its own caster — who gets a - // defender's counteraction window against it (rules rev 25). Earlier - // revisions land the blow instantly and stored games replay so. - if ((state.config.deckRev ?? 1) >= 25 && !stack.trapped && !attackingCreature && + // defender's counteraction window against it. + if (!stack.trapped && !attackingCreature && attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) { state.stack = { attackerId: defender.id, @@ -5800,7 +5657,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } /** - * GO AWAY's rout (rules rev 36): "must move away a number of spaces equal + * GO AWAY's rout: "must move away a number of spaces equal * to the NUMBER card played, in as straight a line as possible. Roll D4 * for random direction, if necessary." The victim keeps moving away, * holding their line while it is open, bending around walls when it is @@ -6007,26 +5864,14 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { const events: GameEvent[] = [ { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, ]; - // WARD: "you may play at that time (out of turn) this card on him." - // From rules rev 31 that is literal: the grab hangs while the owner - // decides. Rev 3-30 committed the choice ahead of time by arming; rev - // 1-2 sprang automatically. Stored games replay their own vintage. + // WARD: "you may play at that time (out of turn) this card on him" — + // literally: the grab hangs while the owner decides. const owner = state.players.find((q) => q.id === t.owner); - const rev = state.config.deckRev ?? 1; if (owner && owner.alive && owner.id !== p.id) { const holdsWard = owner.hand.some((c) => c.cardId === "ward"); - if (rev >= 31 && holdsWard) { + if (holdsWard) { state.wardPending = { ownerId: owner.id, takerId: p.id }; events.push({ type: "wardWindow", owner: owner.id, victim: p.id }); - } else if (holdsWard && (rev >= 3 ? state.wardArmed.includes(owner.id) : true)) { - const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); - const [card] = owner.hand.splice(wardIdx, 1); - owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId); - state.discard.push(card!); - state.wardArmed = state.wardArmed.filter((id) => id !== owner.id); - events.push({ type: "wardSprung", owner: owner.id, victim: p.id }); - applyDamage(state, events, p, 3, "warded treasure", null); - checkVictory(state, events); } } checkAmbushes(state, events, p, { pickedUpTreasure: true }); @@ -6132,9 +5977,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void { p.displayed = []; events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" }); // A treasure in the fallen wizard's arms lands where they stood, as - // it does when a wizard is killed (rules rev 30; earlier games let - // it vanish with them and replay so). - if ((state.config.deckRev ?? 1) >= 30 && p.carriedTreasureId) { + // it does when a wizard is killed. + if (p.carriedTreasureId) { const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!; t.carriedBy = null; t.position = p.position; @@ -6147,7 +5991,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void { onHomeOf: homeOwnerAt(state, p.position), }); } - if ((state.config.deckRev ?? 1) >= 14) sweepEliminated(state, p.id); + sweepEliminated(state, p.id); } } @@ -6226,9 +6070,6 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi c.scorchedThisTurn = []; if (c.kind === "democratic-monster") { c.movementUsed = 0; - // Rev <18 refreshed the claw here — which never fires once the - // first-rolled wizard is dead, since only the living begin turns. - if ((state.config.deckRev ?? 1) < 18 && index === state.turn.firstIndex) c.attackUsed = false; } else if (c.controllerId === player.id) { c.movementUsed = 0; c.wallPassUsed = 0; @@ -6412,9 +6253,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { p.extraTurns--; beginTurnFor(state, events, state.turn.activeIndex); // "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; - // rules rev 8). Self-cast durations already burned in beginTurnFor. - if ((state.config.deckRev ?? 1) >= 8) { + // 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) { @@ -6441,11 +6282,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { state.turn.round++; // The democratic monster's one claw per round refreshes at the TRUE // round boundary — even when the first-rolled wizard is dead or - // skipped and never begins a turn (rules rev 18). - if ((state.config.deckRev ?? 1) >= 18) { - for (const c of state.creatures) { - if (c.kind === "democratic-monster") c.attackUsed = false; - } + // skipped and never begins a turn. + for (const c of state.creatures) { + if (c.kind === "democratic-monster") c.attackUsed = false; } } const candidate = state.players[next]!; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index a7802e8..49bd8d7 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -42,7 +42,8 @@ export interface GameView { winReason: "treasures" | "lastStanding" | null; turn: TurnState; activePlayerId: PlayerId; - /** The game's rules revision — the client mirrors rev-gated legality. */ + /** 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; @@ -70,7 +71,7 @@ export interface GameView { createdEdges: string[]; /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ knownIllusionEdges: string[]; - /** Every illusion edge and YOUR verdict on it (rules rev 29+): untested + /** Every illusion edge and YOUR verdict on it: untested * shimmers, believes renders solid, mine/seesThrough are ghosts. */ illusionEdges: Record; creatures: CreatureState[]; @@ -82,9 +83,7 @@ export interface GameView { outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null; /** CHAOS shield windows in progress (public: everyone sees it coming). */ chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; - /** Whether YOUR ward is set to spring. */ - yourWardArmed: boolean; - /** A grab hangs while the treasure's owner decides their Ward (rev 31). */ + /** A grab hangs while the treasure's owner decides their Ward. */ wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; /** YOUR armed ambushes. Other players' ambushes are invisible. */ yourAmbushes: AmbushState[]; @@ -95,20 +94,17 @@ export interface GameView { export function viewFor(state: GameState, playerId: PlayerId): GameView { const you = state.players.find((p) => p.id === playerId); // Illusion walls render as real walls unless this viewer knows better. - // From rules rev 29 their locations are open knowledge (the cast is - // public at a table) — what stays personal is each player's verdict. + // Their locations are open knowledge (the cast is public at a table) — + // what stays personal is each player's verdict. const base = boardView(state); - const rev29 = (state.config.deckRev ?? 1) >= 29; const knownIllusionEdges: string[] = []; const illusionEdges: Record = {}; let edges = base.edges; for (const [key, wall] of Object.entries(state.illusionWalls)) { const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough"; - if (rev29) { - illusionEdges[key] = - wall.createdBy === playerId ? "mine" - : wall.belief[playerId] ?? "untested"; - } + illusionEdges[key] = + wall.createdBy === playerId ? "mine" + : wall.belief[playerId] ?? "untested"; if (knows) { knownIllusionEdges.push(key); } else { @@ -169,7 +165,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { chaosPending: state.chaosPending ? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] } : null, - yourWardArmed: state.wardArmed.includes(playerId), wardPending: state.wardPending ? { ...state.wardPending } : null, yourAmbushes: state.ambushes .filter((a) => a.ownerId === playerId) @@ -205,9 +200,9 @@ export function sightedCellsFor(view: GameView): Set { /** The board-as-seen and sight blockers this view's sight rules run against. */ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record } { - // Held-open doors are open doorways to the eye (rules rev 15). + // Held-open doors are open doorways to the eye. let board = view.board; - if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) { + if (view.heldDoorEdges.length > 0) { const edges = { ...board.edges }; for (const k of view.heldDoorEdges) delete edges[k]; board = { ...board, edges }; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 4e20984..f9e3c91 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -33,7 +33,6 @@ function playOut( playerIds: ids, seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], - deckRev: 8, }); let commands = 0; let stuck = 0; @@ -120,7 +119,7 @@ describe("automaton vs automaton", () => { }); function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) { - let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 }); + let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"] }); // burn round 1 for (let i = 0; i < 2; i++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -175,7 +174,7 @@ describe("the clockwork guards gold only within sight", () => { // A refused cast forfeits the bot's whole turn (the fallback is endTurn), // so SAFE must only ever be offered where the engine's sight rule allows it. function goldOnTheFloor() { - let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 }); + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -227,7 +226,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. function cursedBot() { - let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 37 }); + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); // Past round 1 (no combat) and around to the bot's turn. for (let i = 0; i < 2; i++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -285,7 +284,7 @@ describe("the clockwork honors IDIOT's march", () => { describe("a clogged hand gets shed, not hoarded", () => { it("the bot discards dead weight so the end-of-turn draw has room", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -331,7 +330,7 @@ describe("the clockwork honors absorb's fine print", () => { describe("the clockwork wields destroy wall", () => { it("blasts its way out when no road leads to the gold", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -353,7 +352,7 @@ describe("the clockwork wields destroy wall", () => { }); it("values destroy wall above the chaff when forced to discard", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] }); const bot = state.players.find((p) => p.id === "bot")!; bot.hand = [ { instanceId: "destroy-wall#T", cardId: "destroy-wall" }, @@ -376,7 +375,7 @@ describe("the clockwork wields destroy wall", () => { describe("the clockwork wields pass through wall", () => { it("banks a crossing when bricked in, then steps through the wall", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -408,7 +407,7 @@ describe("the clockwork wields pass through wall", () => { describe("the clockwork denies the road", () => { it("walls a thief's corridor when its treasure is being carried home", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -446,7 +445,7 @@ describe("the clockwork denies the road", () => { describe("the widened spellbook", () => { it("dispels a conjured wall standing between it and the only road", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -468,7 +467,7 @@ describe("the widened spellbook", () => { }); it("offers a buddy pact to the hound at its heels", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); while (actingSeat(state) !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -490,7 +489,7 @@ describe("the widened spellbook", () => { describe("finishing tactics: exile and adrenaline", () => { it("exiles a thief carrying its gold to the far end of nowhere", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); // burn round 1 and reach the bot's turn for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -518,7 +517,7 @@ describe("finishing tactics: exile and adrenaline", () => { }); it("casts adrenaline when two blows finish what one cannot", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -543,7 +542,7 @@ describe("finishing tactics: exile and adrenaline", () => { describe("the fireball-then-buddy lockout", () => { it("burns the target, then signs the pact so they cannot hit back", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -584,7 +583,7 @@ describe("the fireball-then-buddy lockout", () => { describe("a pact once signed is honored", () => { it("will not attack the wizard it just buddied", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -610,7 +609,7 @@ describe("a pact once signed is honored", () => { describe("no number is wasted on a turn that ends at a grab", () => { it("gold two free steps away: the berserker charges without spending its 5", () => { - let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 29 }); + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] }); for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); @@ -636,7 +635,7 @@ describe("no number is wasted on a turn that ends at a grab", () => { describe("the clockwork flees the dread", () => { it("caught in FEAR's bubble, it spends its legs moving away", () => { - let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 }); + let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"] }); for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); diff --git a/packages/engine/test/board.test.ts b/packages/engine/test/board.test.ts index b543ce6..3f82d6e 100644 --- a/packages/engine/test/board.test.ts +++ b/packages/engine/test/board.test.ts @@ -201,7 +201,7 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => { }); it("relocation discards the aisle warp: only opposite edges connect after", () => { - let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 }); + let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"] }); const bent = (warps: typeof state.board.warps) => warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y); expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists @@ -244,7 +244,7 @@ describe("the aisle warp treats its corner as adjacent — sight included", () = describe("bricking over a warp mouth", () => { it("create wall seals a lettered opening: no passage, no sight", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); // round 1 passes for (let i = 0; i < 2; i++) { const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); @@ -283,7 +283,7 @@ describe("bricking over a warp mouth", () => { describe("breaching the rim (rules rev 19)", () => { it("destroying a perimeter wall opens both sides as a new warp", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); for (let i = 0; i < 2; i++) { const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index d98cc9e..3d7ffa2 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -31,10 +31,10 @@ describe("attack spells", () => { d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" }; state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); - // Full shield stops everything -> stones survive. + // Full shield stops everything -> stones survive. (A total stop + // resolves on the attacker's pass; no bounce back.) state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" }); state = must(state, attacker, { type: "pass" }); - state = must(state, defender, { type: "pass" }); const after = state.players.find((p) => p.id === defender)!; expect(after.life).toBe(15); expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true); @@ -111,7 +111,8 @@ describe("attack spells", () => { state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" }); state = must(state, attacker, { type: "pass" }); - state = must(state, defender, { type: "pass" }); + // The returned spell is a fresh attack on its caster: their window. + state = must(state, attacker, { type: "pass" }); expect(state.players.find((p) => p.id === defender)!.life).toBe(15); expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); }); @@ -308,16 +309,14 @@ describe("stored-log compatibility", () => { }); describe("deck revisions", () => { - it("revision 2 removes LIFESAVER at two players, and only there", () => { - const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 }); + 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) => [...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver"); expect(inGame(two.state)).toBe(false); - // Three players keep it; old two-player games (no deckRev) keep it too. - const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 }); + // Three players keep it. + const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] }); expect(inGame(three.state)).toBe(true); - const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] }); - expect(inGame(legacy.state)).toBe(true); }); }); @@ -380,42 +379,31 @@ describe("attacking walls and doors", () => { }); }); -describe("ward arming and chaos shields (rules rev 3)", () => { - function rev3Game(seed = 42) { - return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 }); +describe("the ward window and chaos shields", () => { + function threeGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] }); } - it("ward springs only when its owner armed it", () => { - let { state } = rev3Game(); + it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => { + let { state } = threeGame(); state = toRound2(state); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); const thief = activePlayer(state); const owner = state.players.find((p) => p.id !== thief.id)!; giveCard(state, owner.id, "ward", "W", 0); + expect(applyCommand(state, owner.id, { type: "armWard", armed: true }).ok).toBe(false); const treasure = state.treasures.find((t) => t.owner === owner.id)!; - - // Unarmed: the grab goes unpunished. thief.position = { ...treasure.position! }; - let s2 = must(state, thief.id, { type: "pickUpTreasure" }); - expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15); - expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true); - - // Armed (on the owner's own turn): the trap bites for 3. - const ownerTurnState = (() => { - let s = state; - while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 }); - return s; - })(); - let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true }); - while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 }); - s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! }; - s3 = must(s3, thief.id, { type: "pickUpTreasure" }); - expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12); - expect(s3.wardArmed).not.toContain(owner.id); + state = must(state, thief.id, { type: "pickUpTreasure" }); + expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id }); + // Spring it: the thief bleeds 3 and the ward is spent. + state = must(state, owner.id, { type: "wardChoice", play: true }); + expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); + expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); it("chaos: bystanders may shield out, reflections are refused", () => { - let { state } = rev3Game(); + let { state } = threeGame(); state = toRound2(state); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); const caster = activePlayer(state); @@ -442,7 +430,7 @@ describe("ward arming and chaos shields (rules rev 3)", () => { }); it("chaos: the defender's full shield sits them out without stopping it", () => { - let { state } = rev3Game(); + let { state } = threeGame(); state = toRound2(state); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); const caster = activePlayer(state); @@ -458,7 +446,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => { }); state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" }); state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti - state = must(state, defender.id, { type: "pass" }); // defender rests on the shield // Bystander declines; the scramble happens without the defender. expect(state.chaosPending?.queue[0]).toBe(bystander.id); state = must(state, bystander.id, { type: "pass" }); @@ -466,17 +453,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => { expect(defenderHand()).toEqual(kept); }); - it("legacy games (rev < 3) keep the automatic ward", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); - state = toRound2(state); - const thief = activePlayer(state); - const owner = state.players.find((p) => p.id !== thief.id)!; - giveCard(state, owner.id, "ward", "W", 0); - const treasure = state.treasures.find((t) => t.owner === owner.id)!; - thief.position = { ...treasure.position! }; - state = must(state, thief.id, { type: "pickUpTreasure" }); - expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); - }); }); describe("zero-damage utility attacks", () => { @@ -517,8 +493,8 @@ describe("zero-damage utility attacks", () => { target: { kind: "player", playerId: defender }, }); state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" }); - state = must(state, attacker, { type: "pass" }); - const result = applyCommand(state, defender, { type: "pass" }); + // 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"); expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true); @@ -539,33 +515,13 @@ 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" }); - state = must(state, defender, { type: "pass" }); const after = state.players.find((p) => p.id === defender)!; expect(after.life).toBe(15); expect(cellKey(after.position)).toBe(cellKey(escape)); }); - it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => { - let { state } = newGame(); - state = toRound2(state); - const { attacker, defender } = faceOff(state); - const fb = giveCard(state, attacker, "fireball"); - const aa = giveCard(state, attacker, "anti-anti", "AA", 1); - const tp = giveCard(state, defender, "teleport", "TP", 0); - const d = state.players.find((p) => p.id === defender)!; - const home = { ...d.position }; - state = must(state, attacker, { - type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, - }); - state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } }); - state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId }); - state = must(state, defender, { type: "pass" }); - const after = state.players.find((p) => p.id === defender)!; - expect(cellKey(after.position)).toBe(cellKey(home)); - expect(after.life).toBe(10); - }); - it("reaches at most four spaces", () => { let { state } = newGame(); state = toRound2(state); @@ -585,7 +541,7 @@ describe("teleport as a counteraction", () => { describe("slime holds spells", () => { function slimeRig() { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const caster = activePlayer(state); const victim = state.players.find((p) => p.id !== caster.id)!; @@ -638,7 +594,6 @@ describe("slime holds spells", () => { state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side }); state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" }); state = must(state, rig.caster, { type: "pass" }); - state = must(state, rig.victim, { type: "pass" }); expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15); expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15); }); @@ -664,7 +619,7 @@ describe("slime holds spells", () => { describe("answering counteractions (FAQ rulings)", () => { function rev6() { - return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 }); + return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); } it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => { @@ -719,7 +674,6 @@ describe("answering counteractions (FAQ rulings)", () => { const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId }); expect(r.ok).toBe(false); state = must(state, attacker, { type: "pass" }); - state = must(state, defender, { type: "pass" }); const after = state.players.find((p) => p.id === defender)!; expect(after.life).toBe(15); expect(cellKey(after.position)).toBe(cellKey(escape)); @@ -728,7 +682,7 @@ describe("answering counteractions (FAQ rulings)", () => { describe("speed and warp-token creation (rules rev 8)", () => { it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); state = toRound2(state); const me = activePlayer(state); const other = state.players.find((p) => p.id !== me.id)!; @@ -747,7 +701,7 @@ describe("speed and warp-token creation (rules rev 8)", () => { }); it("nothing can be created on a dimensional warp token", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const me = activePlayer(state); const spot = { x: me.position.x + 1, y: me.position.y }; @@ -763,7 +717,7 @@ describe("speed and warp-token creation (rules rev 8)", () => { describe("redirection (rules rev 9)", () => { it("the two chosen exits connect; their old partners pair with each other", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); state = toRound2(state); const me = activePlayer(state); // Pick two exits that are NOT already partners. @@ -788,7 +742,7 @@ describe("redirection (rules rev 9)", () => { describe("total stops end the exchange (rules rev 10)", () => { function rev10Game() { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); return { state, attacker, defender }; @@ -839,7 +793,7 @@ describe("total stops end the exchange (rules rev 10)", () => { describe("escapes and elemental walls as counteractions", () => { function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const atk = giveCard(state, attacker, attackId); @@ -901,7 +855,7 @@ describe("escapes and elemental walls as counteractions", () => { describe("empathy as a counteraction", () => { function empathyRig() { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const fb = giveCard(state, attacker, "fireball"); @@ -942,7 +896,7 @@ describe("empathy as a counteraction", () => { describe("empathy mirrors resolution-hook blows (rules rev 16)", () => { it("a believed illusion bites both wizards", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const ill = giveCard(state, attacker, "illusionary-attack"); @@ -963,9 +917,9 @@ describe("empathy mirrors resolution-hook blows (rules rev 16)", () => { }); }); -describe("the reflected-attack window (rules rev 25)", () => { +describe("the reflected-attack window", () => { function reflectRig() { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const sd = giveCard(state, attacker, "sudden-death"); @@ -1007,26 +961,11 @@ describe("the reflected-attack window (rules rev 25)", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(5); expect(state.players.find((p) => p.id === attacker)!.life).toBe(15); }); - - it("rev 24 lands the reflected blow instantly, as stored games replay", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 }); - state = toRound2(state); - const { attacker, defender } = faceOff(state); - const sd = giveCard(state, attacker, "sudden-death"); - giveCard(state, defender, "full-reflection", "FR", 0); - state = must(state, attacker, { - type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender }, - }); - state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" }); - state = must(state, attacker, { type: "pass" }); - expect(state.stack).toBeNull(); - expect(state.players.find((p) => p.id === attacker)!.life).toBe(5); - }); }); -describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => { - function boltRig(deckRev: number) { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev }); +describe("a reflected lightning blast ends the caster's turn", () => { + function boltRig() { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const lb = giveCard(state, attacker, "lightning-blast"); @@ -1044,7 +983,7 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () } it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => { - let { state, attacker } = boltRig(28); + let { state, attacker } = boltRig(); expect(state.players.find((p) => p.id === attacker)!.life).toBe(12); expect(state.turn.actionsEnded).toBe(true); // No more marching, and the end-of-turn draw comes up empty. @@ -1056,19 +995,11 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () expect(state.players.find((p) => p.id === attacker)!.lostTurns + (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1); }); - - it("rev 27 keeps the old behavior: the caster plays on and draws", () => { - let { state, attacker } = boltRig(27); - expect(state.turn.actionsEnded).toBe(false); - const before = state.players.find((p) => p.id === attacker)!.hand.length; - state = must(state, attacker, { type: "endTurn", draw: 2 }); - expect(state.players.find((p) => p.id === attacker)!.hand.length).toBeGreaterThan(before); - }); }); describe("fireball burns only the stones in play (rules rev 33)", () => { it("a displayed stone dies; a hidden one stays secret and safe", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const fb = giveCard(state, attacker, "fireball"); @@ -1086,7 +1017,7 @@ describe("fireball burns only the stones in play (rules rev 33)", () => { describe("stone dead counts only the stones in play (rules rev 34)", () => { it("hidden stones neither add damage nor betray their count", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const sd = giveCard(state, attacker, "stone-dead"); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 09afe12..92f0b20 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -17,7 +17,8 @@ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { describe("expansion deck", () => { it("basic + expansion1 builds the full 200-card game", () => { - const { state } = newGame(); + // Three seats: two-player games shed LIFESAVER from the build. + const { state } = newGame(42, ["a", "b", "c"]); const total = state.deck.length + state.discard.length + state.players.reduce((s, p) => s + p.hand.length, 0); expect(total).toBe(200); @@ -59,6 +60,8 @@ describe("monsters", () => { state = must(state, me, { type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id, }); + // The blow opens the victim's counteraction window; they take it raw. + state = must(state, victim.id, { type: "pass" }); expect(state.players.find((p) => p.id !== me)!.life).toBe(13); }); @@ -136,6 +139,8 @@ 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); expect(bitten.hand.length).toBe(6); @@ -293,9 +298,9 @@ describe("expansion support cards", () => { }); }); -describe("counteracting a creature's blow (rules rev 4)", () => { +describe("counteracting a creature's blow", () => { function rev4() { - return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 }); + return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); } function wraithOnVictim(state: GameState) { state = toRound2(state); @@ -341,20 +346,11 @@ describe("counteracting a creature's blow (rules rev 4)", () => { const wraith = state.creatures.find((c) => c.id === "w1")!; expect(wraith.damage).toBe(2); }); - - it("legacy games (rev < 4) keep the instant touch", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); - const rig = wraithOnVictim(state); - state = rig.state; - state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side }); - expect(state.stack).toBeNull(); - expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13); - }); }); describe("big man (rules rev 5)", () => { function bigRig() { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const giant = activePlayer(state); state.sustained.push({ @@ -450,9 +446,9 @@ describe("big man (rules rev 5)", () => { }); }); -describe("monsters roll to hit the hidden (rules rev 13)", () => { - function creatureVsInvisible(deckRev: number) { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); +describe("monsters roll to hit the hidden", () => { + function creatureVsInvisible() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const me = activePlayer(state).id; const victim = state.players.find((p) => p.id !== me)!; @@ -470,29 +466,18 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => { } it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => { - let { state, me, victim, skeletonId } = creatureVsInvisible(13); + let { state, me, victim, skeletonId } = creatureVsInvisible(); const rngBefore = JSON.stringify(state.rng); state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id }); state = must(state, victim.id, { type: "pass" }); // The die was consumed, whichever way it landed. expect(JSON.stringify(state.rng)).not.toBe(rngBefore); }); - - it("earlier revisions keep the old certainty: no roll, the blow just lands", () => { - let { state, me, victim, skeletonId } = creatureVsInvisible(12); - const lifeBefore = state.players.find((p) => p.id === victim.id)!.life; - const rngBefore = JSON.stringify(state.rng); - state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id }); - state = must(state, victim.id, { type: "pass" }); - expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore); - // No die was consumed on the way. - expect(JSON.stringify(state.rng)).toBe(rngBefore); - }); }); describe("elimination sweeps the board either way (rules rev 14)", () => { it("a treasure-eliminated wizard's fire imp vanishes with them", () => { - let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 }); + let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] }); const victim = state.players.find((p) => p.id === "c")!; state.creatures.push({ id: "imp1", kind: "fire-imp", controllerId: "c", @@ -524,7 +509,7 @@ describe("elimination sweeps the board either way (rules rev 14)", () => { describe("creatures walk open doorways", () => { it("a skeleton passes a door whose lock was removed", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const view = boardView(state); for (const [key, edge] of Object.entries(view.edges)) { @@ -553,7 +538,7 @@ describe("creatures walk open doorways", () => { describe("the wave carries monsters (rules rev 17)", () => { it("a cornered skeleton takes the waterwall crush", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const caster = activePlayer(state); // A skeleton pinned against solid stone, one square from the wave's edge. @@ -592,7 +577,7 @@ describe("the wave carries monsters (rules rev 17)", () => { describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => { it("refreshes each round even with the roll-off winner dead", () => { - let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 }); + let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const firstId = state.players[state.turn.firstIndex]!.id; const dm = { @@ -619,7 +604,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)", () => { it("creation spends the turn, not the round", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const creator = activePlayer(state); const r0 = summon(state, creator.id, "democratic-monster"); @@ -641,9 +626,9 @@ describe("a mid-round democratic monster claws on the next turn (rules rev 18)", }); }); -describe("collapsing walls crush monsters too (rules rev 21)", () => { - function wallRig(deckRev: number) { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev }); +describe("collapsing walls crush monsters too", () => { + function wallRig() { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const view = boardView(state); for (const [key, edge] of Object.entries(view.edges)) { @@ -672,21 +657,16 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => { } it("rev 21: the adjacent troll takes the four points", () => { - const state = wallRig(21); + const state = wallRig(); const troll = state.creatures.find((c) => c.id === "tr1"); // Four points on a six-point troll: hurt but standing (or regenerating). expect(troll?.damage).toBe(4); }); - - it("earlier revisions leave the troll dusty but unharmed", () => { - const state = wallRig(20); - expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0); - }); }); -describe("the self-stack resolves on a pass (rules rev 22)", () => { - function selfTouch(deckRev: number) { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev }); +describe("the self-stack resolves on a pass", () => { + function selfTouch() { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const creator = activePlayer(state); state.creatures.push({ @@ -702,7 +682,7 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => { } it("rev 22: passing your own monster's touch takes the claw and moves on", () => { - const { state, creator } = selfTouch(22); + const { state, creator } = selfTouch(); if (!state.stack) return; // a wall between: the touch never opened expect(state.stack.attackerId).toBe(creator); expect(state.stack.defenderId).toBe(creator); @@ -711,19 +691,11 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => { expect(r.state.stack).toBeNull(); expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13); }); - - it("rev 21 keeps its recorded bounce, no-op as it was", () => { - const { state, creator } = selfTouch(21); - if (!state.stack) return; - const r = applyCommand(state, creator, { type: "pass" }); - if (!r.ok) throw new Error(r.error); - expect(r.state.stack).not.toBeNull(); - }); }); describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => { it("a commanded monster cannot close within three of the fearsome", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 }); + 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. diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index f69187a..28ef118 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -339,8 +339,8 @@ 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" }); - state = must(state, defender, { type: "pass" }); const d = state.players.find((p) => p.id === defender)!; expect(d.life).toBe(19); // gained 4 instead of losing it expect(d.lostTurns).toBe(1); // the stun still applies @@ -349,7 +349,7 @@ describe("cast modifiers", () => { describe("holding the door open (Pick Lock / Master Key)", () => { function doorRig() { - let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 }); + let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] }); state = toRound2(state); // Find a door edge; stand the acting player beside it. const view = boardView(state); @@ -410,9 +410,9 @@ describe("holding the door open (Pick Lock / Master Key)", () => { }); }); -describe("a held door is an open doorway to the eye (rules rev 15)", () => { - function sightRig(deckRev: number) { - let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev }); +describe("a held door is an open doorway to the eye", () => { + function sightRig() { + let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] }); state = toRound2(state); const view = boardView(state); for (const [key, edge] of Object.entries(view.edges)) { @@ -431,7 +431,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => { } it("rev 15: the holder blasts the pursuer through the doorway", () => { - let { state, cell, side, holder, pursuer } = sightRig(15); + let { state, cell, side, holder, pursuer } = sightRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, @@ -447,7 +447,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => { }); it("an unheld unlocked door still blocks sight", () => { - let { state, cell, side, holder, pursuer } = sightRig(15); + let { state, cell, side, holder, pursuer } = sightRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, @@ -459,18 +459,4 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => { }); expect(refused.ok).toBe(false); }); - - it("earlier revisions keep the blocked sightline, hold or no", () => { - let { state, cell, side, holder, pursuer } = sightRig(14); - const pick = giveCard(state, holder, "pick-lock"); - state = must(state, holder, { - type: "cast", instanceId: pick.instanceId, - target: { kind: "edge", cell, side }, params: { hold: true }, - }); - const fb = giveCard(state, holder, "fireball", "FB", 1); - const refused = applyCommand(state, holder, { - type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, - }); - expect(refused.ok).toBe(false); - }); }); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 7ddc118..bce28f1 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -106,7 +106,7 @@ describe("expansion combat cards", () => { expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); }); - it("ward springs when a trapped treasure is grabbed", () => { + it("ward springs in the moment: the grab hangs on the owner's choice", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; @@ -114,6 +114,8 @@ describe("expansion combat cards", () => { const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!; me.position = { ...treasure.position! }; state = must(state, me.id, { type: "pickUpTreasure" }); + expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id }); + state = must(state, enemy.id, { type: "wardChoice", play: true }); expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); @@ -164,7 +166,7 @@ describe("expansion combat cards", () => { it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); state = toRound2(state); const { attacker, defender } = faceOff(state); @@ -198,9 +200,9 @@ describe("expansion combat cards", () => { expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true); }); - it("idiot at rev 37 no longer steers the march — the step goes where asked", () => { + it("idiot never steers the march — the step goes where asked", () => { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 37, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); state = toRound2(state); const { attacker, defender } = faceOff(state); @@ -225,10 +227,10 @@ describe("expansion combat cards", () => { expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to)); }); - it("idiot at rev 37 permits DROP OBJECT that frees the victim's own treasure", () => { - for (const deckRev of [36, 37]) { + it("idiot permits DROP OBJECT that frees the victim's own treasure", () => { + { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); state = toRound2(state); const { attacker, defender } = faceOff(state); @@ -252,13 +254,13 @@ describe("expansion combat cards", () => { type: "cast", instanceId: dr.instanceId, target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" }, }); - expect(r.ok).toBe(deckRev >= 37); + expect(r.ok).toBe(true); } }); it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); state = toRound2(state); const { attacker, defender } = faceOff(state); @@ -394,9 +396,9 @@ describe("ambushes (async interrupts)", () => { }); }); -describe("swap meet trades carried items (rules rev 26)", () => { - function rig(deckRev: number) { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); +describe("swap meet trades carried items", () => { + function rig() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const sm = giveCard(state, attacker, "swap-meet"); @@ -412,21 +414,15 @@ describe("swap meet trades carried items (rules rev 26)", () => { } it("a dagger trades for a wand — both are carried items", () => { - const { state, attacker, defender } = rig(26); + const { state, attacker, defender } = rig(); expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true); }); - - it("older revisions matched only object-typed cards and replay so", () => { - const { state, attacker, defender } = rig(25); - expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true); - expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); - }); }); describe("swap meet trades treasures too", () => { function treasureRig(theirsToken: string, mineToken: string) { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; @@ -459,7 +455,7 @@ describe("swap meet trades treasures too", () => { it("the trade refuses to overload full arms", () => { // The attacker also carries a treasure: claiming theirs with a dagger // would mean two in hand — the swap quietly cannot happen. - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; @@ -481,7 +477,7 @@ describe("swap meet trades treasures too", () => { }); it("treasure for treasure trades both armfuls", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; @@ -506,7 +502,7 @@ describe("swap meet trades treasures too", () => { describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => { function reflectedSwapRig(reflectorChoice?: string) { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; @@ -553,7 +549,7 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)", describe("go away routs around walls (rules rev 36)", () => { it("a victim against a wall bends the line instead of standing still", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 }); + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index f76a8a3..35fdc93 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -162,10 +162,10 @@ describe("expansion terrain", () => { // Wave side effects vary by geometry; the melt itself is the pinned behavior. }); - it("wall of fire takes a rim warp from rev 39 — both mouths burn the crossing", () => { - for (const deckRev of [38, 39]) { + it("wall of fire takes a rim warp — both mouths burn the crossing", () => { + { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const warp = state.board.warps[0]!; @@ -175,8 +175,7 @@ describe("expansion terrain", () => { type: "cast", instanceId: wof.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); - expect(r.ok).toBe(deckRev >= 39); - if (!r.ok) continue; + if (!r.ok) throw new Error(r.error); state = r.state; const nearKey = edgeKey(warp.from.cell, warp.from.side); const farKey = edgeKey(warp.to.cell, warp.to.side); @@ -191,10 +190,10 @@ describe("expansion terrain", () => { } }); - it("waterwall takes a rim warp from rev 40 — the collapse washes both rims", () => { - for (const deckRev of [39, 40]) { + it("waterwall takes a rim warp — the collapse washes both rims", () => { + { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const other = state.players.find((p) => p.id !== me.id)!; @@ -206,8 +205,7 @@ describe("expansion terrain", () => { type: "cast", instanceId: ww.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); - expect(r.ok).toBe(deckRev >= 40); - if (!r.ok) continue; + if (!r.ok) throw new Error(r.error); // Both wizards stood in the mouths; the collapse washed both inward. const meAfter = r.state.players.find((p) => p.id === me.id)!; const otherAfter = r.state.players.find((p) => p.id === other.id)!; @@ -216,10 +214,10 @@ describe("expansion terrain", () => { } }); - it("illusion wall hangs on a rim warp mouth from rev 40", () => { - for (const deckRev of [39, 40]) { + it("illusion wall hangs on a rim warp mouth", () => { + { const { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const warp = state.board.warps[0]!; @@ -229,17 +227,17 @@ describe("expansion terrain", () => { type: "cast", instanceId: il.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); - expect(r.ok).toBe(deckRev >= 40); + expect(r.ok).toBe(true); if (r.ok) { expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined(); } } }); - it("stone to water breaches the outer rim from rev 40, opening a warp", () => { - for (const deckRev of [39, 40]) { + it("stone to water breaches the outer rim, opening a warp", () => { + { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const board = boardView(state); @@ -264,20 +262,15 @@ describe("expansion terrain", () => { type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: rim.cell, side: rim.side }, }); - if (deckRev >= 40) { - // The far rim melted with it and the wraparound now runs. - expect(state.board.warps.length).toBe(warpsBefore + 2); - } else { - // Older vintages left a dangling opening and replay so. - expect(state.board.warps.length).toBe(warpsBefore); - } + // 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 from rev 40", () => { - for (const deckRev of [39, 40]) { + it("no doors through the rim", () => { + { const { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const board = boardView(state); @@ -299,15 +292,14 @@ describe("expansion terrain", () => { type: "cast", instanceId: cd.instanceId, target: { kind: "edge", cell: rim.cell, side: rim.side }, }); - // Rev 39 hung a dead door there (and replays so); rev 40 refuses. - expect(r.ok).toBe(deckRev < 40); + expect(r.ok).toBe(false); } }); - it("stone to water melts a door from rev 38 — a small entryway in a stone wall", () => { - for (const deckRev of [37, 38]) { + 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"], deckRev, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const board = boardView(state); @@ -329,7 +321,7 @@ describe("expansion terrain", () => { type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: doorAt.cell, side: doorAt.side }, }); - expect(r.ok).toBe(deckRev >= 38); + expect(r.ok).toBe(true); if (r.ok) { const key = edgeKey(doorAt.cell, doorAt.side); expect(boardView(r.state).edges[key] ?? "open").toBe("open"); @@ -389,11 +381,11 @@ describe("eligibility dimming mirrors the engine", () => { }); }); -describe("a wave's force is spent as it travels (rules rev 24)", () => { +describe("a wave's force is spent as it travels", () => { /** Caster at B, one cell above A; the target wall is A's south edge, so * the range-2 wave covers A (dist 0) and B (dist 1). */ - function rig(deckRev: number, behind: "open" | "wall") { - const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + function rig(behind: "open" | "wall") { + const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); const me = activePlayer(state); const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 }; for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy(); @@ -412,20 +404,15 @@ describe("a wave's force is spent as it travels (rules rev 24)", () => { } it("a victim at the wave's far edge is carried one space, unhurt", () => { - const { me, Bn } = rig(24, "open"); + const { me, Bn } = rig("open"); expect(cellKey(me.position)).toBe(cellKey(Bn)); expect(me.life).toBe(15); }); it("only unspent force crushes: one space of push blocked is one damage", () => { - const { me, B } = rig(24, "wall"); + const { me, B } = rig("wall"); expect(cellKey(me.position)).toBe(cellKey(B)); expect(me.life).toBe(14); }); - it("older revisions keep the flat full-range wash for replay fidelity", () => { - const { me, Bnn } = rig(23, "open"); - expect(cellKey(me.position)).toBe(cellKey(Bnn)); - expect(me.life).toBe(15); - }); }); diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 352102a..8d0b335 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -229,7 +229,7 @@ describe("treasures and victory", () => { describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => { it("the carried treasure lands where the wizard stood", () => { - let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 }); + let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] }); const A = state.players.find((p) => p.id === "a")!; const B = state.players.find((p) => p.id === "b")!; const C = state.players.find((p) => p.id === "c")!; @@ -267,7 +267,7 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)", describe("the Ward is played in the moment (rules rev 31)", () => { function grabRig() { - let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 }); + let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] }); const thief = state.players.find((p) => p.id === "thief")!; const owner = state.players.find((p) => p.id === "owner")!; giveCard(state, "owner", "ward", "W", 0); @@ -307,7 +307,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => { }); it("arming is refused in this vintage", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 }); + 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 }); expect(r.ok).toBe(false); @@ -316,7 +316,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => { describe("an ambushed teleport carries its destination (rules rev 35)", () => { it("the trap springs and the victim lands where the trapper said", () => { - let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 }); + 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++) { const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index b66bc97..05f34f2 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -143,13 +143,16 @@ describe("illusion wall", () => { expect(after.ok).toBe(true); }); - it("opponents test the illusion when they bump it — some see through, some believe", () => { + it("an untested illusion blocks the bump; a tested eye rolls its verdict", () => { let believed = 0, sawThrough = 0; for (let seed = 1; seed <= 12; seed++) { const { state, caster, side } = setupIllusion(seed); const other = state.players.find((p) => p.id !== caster)!; other.position = { ...state.players.find((p) => p.id === caster)!.position }; let s = must(state, caster, { type: "endTurn", draw: 0 }); + // Bumping blind is refused: eyes must be tested first. + expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false); + s = must(s, other.id, { type: "testIllusion", cell: other.position, side }); const result = applyCommand(s, other.id, { type: "move", direction: side }); if (result.ok) sawThrough++; else believed++; @@ -223,11 +226,16 @@ describe("sector manipulation", () => { type: "cast", instanceId: rel.instanceId, target: { kind: "cell", cell: dest }, params: { cell: me.position }, }); + // The maze zero-anchors after the move; measure against the sector's + // origin as it settled, not the requested landing. const after = state.players.find((p) => p.id === me.id)!; - const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y; + const newOrigin = state.board.placements[idx]!.origin; + const dx = newOrigin.x - myOrigin.x, dy = newOrigin.y - myOrigin.y; expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy }); expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy }); - expect(state.board.placements[idx]!.origin).toEqual(dest); + // My sector sits east of the other, as asked. + const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin; + expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y }); // The map reassembled: every treasure/wizard cell exists on the new board. for (const t of state.treasures) { if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true); @@ -319,8 +327,8 @@ 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" }); - state = must(state, defender.id, { type: "pass" }); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15); // But it cannot counter a fireball. @@ -337,7 +345,7 @@ describe("6e card-face corrections", () => { describe("relocation past the origin (rules rev 11)", () => { function rev11Game() { - const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 }); + const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); return state; } @@ -438,7 +446,7 @@ describe("relocation past the origin (rules rev 11)", () => { describe("junction alterations roll for their sector (rules rev 20)", () => { 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"], deckRev: 20 }); + const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); let state = toRound2(fresh); // The 2p board is two sectors stacked: the seam runs between them. const [p0, p1] = state.board.placements; @@ -503,7 +511,7 @@ describe("sight tracing", () => { describe("illusions are tested by choice (rules rev 29)", () => { function shimmerRig() { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); state = toRound2(state); const caster = activePlayer(state); const mark = state.players.find((p) => p.id !== caster.id)!; diff --git a/packages/engine/test/terrain-objects-control.test.ts b/packages/engine/test/terrain-objects-control.test.ts index aecca54..cb83ebb 100644 --- a/packages/engine/test/terrain-objects-control.test.ts +++ b/packages/engine/test/terrain-objects-control.test.ts @@ -280,7 +280,7 @@ describe("control effects", () => { describe("picking one of two treasures", () => { it("a named treasure is the one taken", () => { - let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 }); + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); state = toRound2(state); const p = activePlayer(state); const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id); diff --git a/packages/engine/test/wands.test.ts b/packages/engine/test/wands.test.ts index b7981af..200997c 100644 --- a/packages/engine/test/wands.test.ts +++ b/packages/engine/test/wands.test.ts @@ -145,7 +145,7 @@ describe("magic wands", () => { it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => { let { state } = createGame({ - playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 40, + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const view = boardView(state); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index d804461..0fb9f0a 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -53,8 +53,10 @@ export interface Room { const rooms = new Map(); -/** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 40; +/** 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. */ +const RULES_REV = 1; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 291d877..fefed52 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -227,8 +227,6 @@ const k = `${c.x},${c.y}`; if (seen.has(k)) continue; seen.add(k); - // Before rev 11 the grid stopped at zero; newer games renormalize. - if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue; if (origins.some((o) => o.x === c.x && o.y === c.y)) continue; const next = origins.map((o, j) => (j === idx ? c : o)); if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c); @@ -255,7 +253,6 @@ }); clearSelection(); } - const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false); /** A live moment to interrupt: another wizard acts, no attack is pending. */ const canInterruptNow = $derived( view != null && !isYourTurn && !view.stack && !view.chaosPending && @@ -400,7 +397,7 @@ // Fully reflecting a SWAP MEET: "lets the other player decide which // objects, if any, will be swapped" — the choice rides the counter. if (youMustRespond && card.cardId === "full-reflection" && - view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) { + view.stack?.attackCard?.cardId === "swap-meet") { reflectSwapCard = card; reflectSwapMine = null; return; @@ -912,11 +909,8 @@ reflectSwapCard = null; reflectSwapMine = null; } - // Older rooms' rules trade only object-typed cards (rev < 26): the picker - // must offer exactly what that room's engine will honor. function tradableHere(cardId: string): boolean { - if (!view) return false; - return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object"; + return view != null && isMovableObject(cardId); } /** What one side can put on a trade table: hand (yours) or displayed * cards (theirs), plus a carried treasure. Their treasure is claimable @@ -1753,9 +1747,6 @@ {/if} - {#if view.yourWardArmed} -
🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.
- {/if} {#if youMustShield}
Chaos comes for your hand — tap your Full Shield to sit out, or @@ -1808,20 +1799,12 @@ {/if} {#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
- {#if view.deckRev >= 37} - 🥴 "What am I doing here…?" — IDIOT drives you to the nearest - of your own treasures. Spend your movement marching toward it; - you may cast only counteractions, spells that speed the trip, - and Drop Object to shake your own treasure from a thief's - arms. It lifts when you stand on your treasure. (The marching - is yours to honor.) - {:else} - 🥴 "What am I doing here…?" — IDIOT drives you to the nearest - of your own treasures. Spend your movement marching (the maze - will steer your steps); you may cast only counteractions and - spells that speed the trip. It lifts when you stand on your - treasure. - {/if} + 🥴 "What am I doing here…?" — IDIOT drives you to the nearest + of your own treasures. Spend your movement marching toward it; + you may cast only counteractions, spells that speed the trip, + and Drop Object to shake your own treasure from a thief's + arms. It lifts when you stand on your treasure. (The marching + is yours to honor.)
{/if} {#if actionsSpent} @@ -2136,10 +2119,6 @@ onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> Pick up {cardDef(obj.cardId).name} {/each} - {#if holdingWard && view.deckRev < 31} - - {/if} diff --git a/packages/web/src/hints.ts b/packages/web/src/hints.ts index 8e800c5..68b55b9 100644 --- a/packages/web/src/hints.ts +++ b/packages/web/src/hints.ts @@ -63,8 +63,6 @@ function describe(view: GameView, cmd: Command): string { return `set the monster on ${cmd.targetId}`; case "setAmbush": return `lay an ambush with ${nameOf(view, cmd.instanceId)}`; - case "armWard": - return cmd.armed ? "arm the Ward over the gold" : "stand the Ward down"; case "discard": return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`; case "endTurn": diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index a03dd1a..03da767 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,6 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 36, }; const { state, events } = createGame(config); for (const e of events) { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index cb21b52..dee3944 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -418,6 +418,18 @@ class Net { if (g.yourTurn && !was) this.notifyTurn(g); this.lastYourTurn.set(key, g.yourTurn); } + // 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.) + 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}`)); + if (kept.length !== this.seats.length) { + this.seats = kept; + saveSeats(this.seats); + } + } break; } case "error":