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