diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index e450195..2a8816a 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -225,6 +225,9 @@ export interface CastParams { hold?: boolean; } +/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ +export const CURRENT_RULES_REV = 2; + export interface GameConfig { playerIds: PlayerId[]; seed: number; @@ -235,17 +238,10 @@ export interface GameConfig { colors?: number[]; /** * Rules revision, frozen per game so stored games replay unchanged. - * Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not - * applicable in a 2-player game."). Rev 3: WARD springs only when armed, - * and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4: - * creature blows open a counteraction window like any attack. Rev 5: - * BIG MAN pushes occupants ahead, steps over floor hazards for 2 points, - * and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a - * teleport escape ("does not work against escape" — the card face). - * Rev 7: no behavioral change — every board opening carries warp sight - * in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the - * warp." — the card face). Rev 8: nothing can be created on a warp token, - * and a SPEED bonus turn burns a turn of durations on the hastened wizard. + * Absent = rev 1, the baseline all earlier revisions collapsed into. + * Rev 2: a wizard beside a door they can open (lock removed, door + * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through + * the doorway; the hallway behind them still cannot. */ deckRev?: number; } @@ -348,19 +344,42 @@ 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 - * the table holds doors open precisely to cast back through them. */ -function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard { +/** A door HELD open is propped ajar for every eye in the hallway. Any + * other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar + * for the one wizard who may pull it open and peek. */ +function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard { if (state.heldDoors.length === 0) return board; const edges = { ...board.edges }; for (const h of state.heldDoors) delete edges[h.key]; return { ...board, edges }; } +/** A wizard at a door's threshold may pull it open and look through + * without stepping through: any wizard beside a door whose lock is + * REMOVED or that stands unlocked this turn, and a holder of PICK LOCK + * or MASTER KEY beside any workable lock. The door still hangs shut to + * everyone down the hallway. (Rules rev 2; earlier games replay with + * doors dark.) */ +function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard { + if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board; + const viewer = state.players.find((p) => p.id === viewerId); + if (!viewer || !viewer.alive) return board; + const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key"); + let edges: Record | null = null; + for (const side of SIDES) { + const key = edgeKey(viewer.position, side); + if (board.edges[key] !== "door") continue; + const workable = carriesKey && state.doorStates[key] !== "jammed"; + if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue; + if (!edges) edges = { ...board.edges }; + delete edges[key]; + } + return edges ? { ...board, edges } : board; +} + /** LOS including square-filling blockers. */ -export function gameLos(state: GameState, from: Cell, to: Cell): boolean { - return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state)); +export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { + return sightBetween(doorsAjar(state, viewerId, openedDoors(state, boardView(state))), from, to, losBlockers(state)); } /** Parse an edge key back into its north/west cell and side. */ @@ -409,7 +428,7 @@ function casterLos( from: Cell, to: Cell, ): boolean { - const board = openHeldDoors(state, perceivedBoard(state, caster.id)); + const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id))); const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; @@ -1306,7 +1325,7 @@ const CARD_EFFECTS: Record if (cmd.target.playerId === caster.id) return "you are already your own buddy"; const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return "no such living player"; - if (!gameLos(state, caster.position, target.position)) return "no line of sight"; + if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; // Effectively permanent: broken by the caster attacking the target. attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS); return null; @@ -1474,14 +1493,14 @@ const CARD_EFFECTS: Record const key = cellKey(cmd.target.cell); const creature = creatureAt(state, cmd.target.cell); if (creature) { - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; destroyCreature(state, events, creature, "dispel creation"); events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind }); return null; } const content = state.squareContents[key]; if (!content) return "nothing created there"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); return null; @@ -1498,7 +1517,7 @@ const CARD_EFFECTS: Record const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return "no such living player"; if (target.id === caster.id) return "you cannot drag yourself"; - if (!gameLos(state, caster.position, target.position)) return "no line of sight"; + if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; if (isLockedInPlace(state, target.id)) return "they are locked in place"; const from = target.position; dragToward(state, target, caster.position); @@ -1507,7 +1526,7 @@ const CARD_EFFECTS: Record } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; const objects = state.groundObjects[key]; const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key); if (objects && objects.length > 0) { @@ -1584,7 +1603,7 @@ const CARD_EFFECTS: Record resolve: (state, events, caster) => { for (const opp of state.players) { if (!opp.alive || opp.id === caster.id) continue; - if (!gameLos(state, caster.position, opp.position)) continue; + if (!gameLos(state, caster.position, opp.position, caster.id)) continue; if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue; retreatFromSight(state, events, opp, caster.position); } @@ -1680,7 +1699,7 @@ const CARD_EFFECTS: Record const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); if (!creature) return "no such monster"; if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster"; - if (!gameLos(state, caster.position, creature.position)) return "no line of sight"; + if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight"; const boost = cmd.params?.boost === "movement" ? "movement" : "life"; if (boost === "movement") creature.movesPerTurn *= 2; else creature.maxDamage *= 2; @@ -1882,7 +1901,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to glue down"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; state.gluedCells[key] = true; // "a duration equal to twice the NUMBER card played" const turns = magnitude.duration * 2; @@ -1906,7 +1925,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to lock up"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id }; events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); return null; @@ -1924,7 +1943,7 @@ const CARD_EFFECTS: Record if (ka === kb) return "pick two different squares"; if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe"; - if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight"; + if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight"; const itemsA = state.groundObjects[ka] ?? []; const itemsB = state.groundObjects[kb] ?? []; const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); @@ -1987,7 +2006,7 @@ const CARD_EFFECTS: Record if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; - if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; + if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); // "Solid stone block turns into a WATERWALL with a range and damage of 4." @@ -2536,7 +2555,7 @@ const CARD_EFFECTS: Record const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!other || !other.alive) return "no such living player"; if (other.id === caster.id) return "that is already your home"; - if (!gameLos(state, caster.position, other.position)) return "no line of sight to them"; + if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them"; const onHome = (home: Cell) => state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length; if (onHome(caster.home) !== onHome(other.home)) { @@ -2727,7 +2746,7 @@ function emptySquareTarget( 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"; + if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight"; return cell; } @@ -3370,8 +3389,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb const d = seen.get(cellKey(cur))!; if (d >= 8) break; // teleport range is 4; stop early for (const side of SIDES) { - const n = neighbor(cur, side); - if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue; + let n = neighbor(cur, side); + if (!board.cells[cellKey(n)]) { + // The maze wraps for walkers; it wraps for teleporters too — + // a warp mouth is one step, same as any doorway. + const w = board.warps.find( + (w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side, + ); + if (!w) continue; + n = w.to.cell; + } + if (seen.has(cellKey(n))) continue; seen.set(cellKey(n), d + 1); if (cellKey(n) === cellKey(to)) return d + 1; queue.push(n); @@ -3384,6 +3412,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb // Setup export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } { + config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV }; const n = config.playerIds.length; let rng = createRng(config.seed); const events: GameEvent[] = []; @@ -3747,11 +3776,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: if (sustainedOn(state, other.id, "fear").length === 0) continue; const d = dreadDistance(board, other.position, to); const dBefore = dreadDistance(board, other.position, from); - if (d <= 3 && d < dBefore) return true; + if (d <= 3 && d < dBefore) { + // Approaching from outside the dread is never willing. + if (dBefore > 3) return true; + // Already caged inside it: the maze's corners may force a step + // that closes the crow-flies distance — permitted only when the + // step walks the shortest way OUT of the bubble. + const eFrom = escapeSteps(state, board, other.position, from); + const eTo = escapeSteps(state, board, other.position, to); + if (!(eTo < eFrom)) return true; + } } return false; } +/** + * Walking steps (walls and shut doors respected, warp mouths crossed, + * fire braved) from `start` to the nearest cell beyond a dread bubble + * centered on `dreadFrom`. Infinity when no way out exists. + */ +function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number { + if (dreadDistance(board, dreadFrom, start) > 3) return 0; + const passable = (cell: Cell, side: Side): Cell | null => { + const key = edgeKey(cell, side); + const e = board.edges[key] ?? "open"; + if (e === "wall") return null; + if (e === "door" && + !state.openDoorEdges.includes(key) && + !state.heldDoors.some((h) => h.key === key)) return null; + let n = neighbor(cell, side); + if (!board.cells[cellKey(n)]) { + const w = board.warps.find( + (w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side, + ); + if (!w) return null; + n = w.to.cell; + } + if (state.squareContents[cellKey(n)]?.kind === "stone") return null; + return n; + }; + const seen = new Set([cellKey(start)]); + const queue: [Cell, number][] = [[start, 0]]; + while (queue.length > 0) { + const [cur, d] = queue.shift()!; + for (const side of SIDES) { + const n = passable(cur, side); + if (!n || seen.has(cellKey(n))) continue; + if (dreadDistance(board, dreadFrom, n) > 3) return d + 1; + seen.add(cellKey(n)); + queue.push([n, d + 1]); + } + } + return Infinity; +} + /** A blind wizard's wasted lurch into a wall still costs a movement point. */ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult { state.turn.movementUsed++; @@ -4316,7 +4394,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, p.id)); + const board = doorsAjar(state, p.id, openedDoors(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"); @@ -5089,8 +5167,8 @@ function checkAmbushes( sprung = context.pickedUpTreasure === true; } else if (context.movedFrom) { if (ambush.trigger.kind === "los") { - const before = gameLos(state, owner.position, context.movedFrom); - const now = gameLos(state, owner.position, actor.position); + const before = gameLos(state, owner.position, context.movedFrom, owner.id); + const now = gameLos(state, owner.position, actor.position, owner.id); sprung = now && !before; } else if (ambush.trigger.kind === "near") { const dist = (c: Cell) => @@ -5102,7 +5180,7 @@ function checkAmbushes( // The committed spell must be legal right now, or the ambush stays armed. const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; - if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue; + if (fx.requiresLos && !gameLos(state, owner.position, actor.position, owner.id)) continue; state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 5203394..99860b4 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,7 +2,7 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; +import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { cardDef, type CardInstance } from "./cards"; import { boardView, @@ -62,6 +62,8 @@ export interface GameView { /** Safes standing open (their combination entered this turn). */ openSafes: string[]; groundObjects: Record; + /** The rules revision this game was dealt under. */ + deckRev: number; doorStates: Record; /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; @@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { groundObjects: Object.fromEntries( Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), ), + deckRev: state.config.deckRev ?? 1, doorStates: { ...state.doorStates }, wallDamage: { ...state.wallDamage }, openDoorEdges: [...state.openDoorEdges], @@ -202,11 +205,25 @@ 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. + // Held-open doors are open doorways to every eye. Beyond them, the + // viewer at a door's threshold may pull it open and peek (rules rev 2): + // lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in + // hand — mirroring the engine's doorsAjar. let board = view.board; - if (view.heldDoorEdges.length > 0) { + const openKeys = new Set(view.heldDoorEdges); + const me = view.players.find((p) => p.id === view.you); + if (view.deckRev >= 2 && me?.alive) { + const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key"); + for (const side of SIDES) { + const key = edgeKey(me.position, side); + if (board.edges[key] !== "door") continue; + const workable = carriesKey && view.doorStates[key] !== "jammed"; + if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key); + } + } + if (openKeys.size > 0) { const edges = { ...board.edges }; - for (const k of view.heldDoorEdges) delete edges[k]; + for (const k of openKeys) delete edges[k]; board = { ...board, edges }; } const blockers: Record = {}; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index eed75a3..35937a5 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -500,6 +500,28 @@ describe("zero-damage utility attacks", () => { }); }); +describe("teleport wraps the maze's rim", () => { + it("four spaces counted through a warp mouth reach the far side", () => { + let { state } = newGame(); + state = toRound2(state); + // Stand the active wizard at a wraparound mouth; the paired cell is + // ONE teleport step away, exactly as it is one walking step. + const active = activePlayer(state); + const warp = boardView(state).warps[0]!; + active.position = { ...warp.from.cell }; + const tp = giveCard(state, active.id, "teleport", "TP", 0); + const r = applyCommand(state, active.id, { + type: "cast", instanceId: tp.instanceId, + target: { kind: "cell", cell: { ...warp.to.cell } }, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const after = r.state.players.find((p) => p.id === active.id)!; + expect(cellKey(after.position)).toBe(cellKey(warp.to.cell)); + } + }); +}); + describe("teleport as a counteraction", () => { it("the attack has no chance of hitting you — you are simply elsewhere", () => { let { state } = newGame(); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 424e6c8..dd0656e 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -720,4 +720,35 @@ describe("fear holds off monsters and unwilling feet alike", () => { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain("dread"); }); + + it("a wizard caged inside the dread may round a corner to escape", () => { + 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")!; + pushSustained(state, { + id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b", + remainingTurns: 5, data: {}, + }); + // b radiates dread from (2,5). a stands in a dead-end pocket at + // (2,3): walls on three sides, so the only way out steps SOUTH — + // closer to b — before the corridor east leads clear of the dread. + b.position = { x: 2, y: 5 }; + a.position = { x: 2, y: 3 }; + for (const side of ["N", "E", "W"] as const) { + state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall"; + } + state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open"; + while (state.players[state.turn.activeIndex]!.id !== "a") { + const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); + if (!r0.ok) throw new Error(r0.error); + state = r0.state; + } + // The closer step is the only way out: permitted. + const r = applyCommand(state, "a", { type: "move", direction: "S" }); + if (!r.ok) throw new Error("escape step refused: " + r.error); + expect(r.ok).toBe(true); + }); }); diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 5dc421a..ad91402 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -445,7 +445,7 @@ describe("a held door is an open doorway to the eye", () => { expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore); }); - it("an unheld unlocked door still blocks sight", () => { + it("an unlocked door is an open doorway to the wizard at its threshold", () => { let { state, cell, side, holder, pursuer } = sightRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { @@ -453,9 +453,56 @@ describe("a held door is an open doorway to the eye", () => { target: { kind: "edge", cell, side }, }); const fb = giveCard(state, holder, "fireball", "FB", 1); - const refused = applyCommand(state, holder, { + const cast = applyCommand(state, holder, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, }); + expect(cast.ok).toBe(true); + }); + + it("a lock REMOVED lets any wizard beside the door peek through", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + const rm = giveCard(state, holder, "remove-lock"); + state = must(state, holder, { + type: "cast", instanceId: rm.instanceId, + target: { kind: "edge", cell, side }, + }); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const cast = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(cast.ok).toBe(true); + }); + + it("PICK LOCK in hand is enough to ease a locked door open a crack", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + giveCard(state, holder, "pick-lock"); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const cast = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(cast.ok).toBe(true); + }); + + it("the wizard down the hallway sees no further than the shut door", () => { + let { state, cell, side, holder, pursuer } = sightRig(); + // Pull the pursuer one square further down the hall, so the door is + // no longer on their threshold; clear their path to the doorway. + const near = neighbor(cell, side); + const far = neighbor(near, side); + if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable + state.players.find((p) => p.id === pursuer)!.position = far; + state.edgeOverrides[edgeKey(near, side)] = "open"; + const rm = giveCard(state, holder, "remove-lock"); + state = must(state, holder, { + type: "cast", instanceId: rm.instanceId, + target: { kind: "edge", cell, side }, + }); + state = must(state, holder, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, pursuer, "fireball", "FB", 1); + const refused = applyCommand(state, pursuer, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder }, + }); expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("line of sight"); }); }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 76974a5..8279f29 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -20,6 +20,7 @@ import { type GameState, type GameView, type PlayerId, + CURRENT_RULES_REV, } from "@wizwar/engine"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; @@ -56,7 +57,7 @@ const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). * 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 RULES_REV = CURRENT_RULES_REV; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";