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:
co-authored by
Claude Fable 5
parent
2932d1e500
commit
154e00141c
+116
-38
@@ -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);
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user