Three rulings from the night games (rules rev 2)

Teleport wraps the maze's rim: its four counted squares now step
through warp mouths like any doorway, so a wizard can blink off one
side of the board and onto the other.

A wizard at a door's threshold may pull it open and peek — lock
removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand —
without stepping through. The door still hangs shut to everyone down
the hallway; only a HELD door is propped open for every eye. Gated
behind rules rev 2 (now sourced from the engine's CURRENT_RULES_REV)
so stored games replay with their doors dark; the client's sight
mirror learns the same peek.

Fear no longer nails a cornered wizard to the floor: a step closer to
the dread is allowed when it walks the shortest way out of it, so
escape can round a corner. Standing outside and stepping in is still
refused.

All 15 production ledgers verified before deploy; 269 engine tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-25 14:28:12 -04:00
co-authored by Claude Fable 5
parent 2932d1e500
commit 154e00141c
6 changed files with 241 additions and 45 deletions
+116 -38
View File
@@ -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<string, true> {
return blockers;
}
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
* considered to block L.O.S." speaks of a CLOSED door, and
* 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<string, EdgeState> | 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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
}
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
(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<string, AttackEffect | NeutralEffect | CounterEffect>
(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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string>([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);
+21 -4
View File
@@ -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<string, CardInstance[]>;
/** The rules revision this game was dealt under. */
deckRev: number;
doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
@@ -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<string> {
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// 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<string, true> = {};
+22
View File
@@ -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();
+31
View File
@@ -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);
});
});
@@ -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");
});
});
+2 -1
View File
@@ -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<string, Room>();
/** 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";