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;
|
hold?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
|
export const CURRENT_RULES_REV = 2;
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -235,17 +238,10 @@ export interface GameConfig {
|
|||||||
colors?: number[];
|
colors?: number[];
|
||||||
/**
|
/**
|
||||||
* Rules revision, frozen per game so stored games replay unchanged.
|
* Rules revision, frozen per game so stored games replay unchanged.
|
||||||
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
|
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
||||||
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
|
* Rev 2: a wizard beside a door they can open (lock removed, door
|
||||||
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
|
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
|
||||||
* creature blows open a counteraction window like any attack. Rev 5:
|
* the doorway; the hallway behind them still cannot.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -348,19 +344,42 @@ export function losBlockers(state: GameState): Record<string, true> {
|
|||||||
return blockers;
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
|
/** A door HELD open is propped ajar for every eye in the hallway. Any
|
||||||
* considered to block L.O.S." speaks of a CLOSED door, and
|
* other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar
|
||||||
* the table holds doors open precisely to cast back through them. */
|
* for the one wizard who may pull it open and peek. */
|
||||||
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||||
if (state.heldDoors.length === 0) return board;
|
if (state.heldDoors.length === 0) return board;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
for (const h of state.heldDoors) delete edges[h.key];
|
for (const h of state.heldDoors) delete edges[h.key];
|
||||||
return { ...board, edges };
|
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. */
|
/** LOS including square-filling blockers. */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
||||||
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
|
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. */
|
/** Parse an edge key back into its north/west cell and side. */
|
||||||
@@ -409,7 +428,7 @@ function casterLos(
|
|||||||
from: Cell,
|
from: Cell,
|
||||||
to: Cell,
|
to: Cell,
|
||||||
): boolean {
|
): 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);
|
const blockers = losBlockers(state);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
if (!displays(caster, "visionstone")) return false;
|
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";
|
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);
|
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 || !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.
|
// Effectively permanent: broken by the caster attacking the target.
|
||||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||||
return null;
|
return null;
|
||||||
@@ -1474,14 +1493,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
const creature = creatureAt(state, cmd.target.cell);
|
const creature = creatureAt(state, cmd.target.cell);
|
||||||
if (creature) {
|
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");
|
destroyCreature(state, events, creature, "dispel creation");
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = state.squareContents[key];
|
const content = state.squareContents[key];
|
||||||
if (!content) return "nothing created there";
|
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];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||||
return null;
|
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);
|
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 || !target.alive) return "no such living player";
|
||||||
if (target.id === caster.id) return "you cannot drag yourself";
|
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";
|
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||||
const from = target.position;
|
const from = target.position;
|
||||||
dragToward(state, target, caster.position);
|
dragToward(state, target, caster.position);
|
||||||
@@ -1507,7 +1526,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.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 objects = state.groundObjects[key];
|
||||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||||
if (objects && objects.length > 0) {
|
if (objects && objects.length > 0) {
|
||||||
@@ -1584,7 +1603,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
resolve: (state, events, caster) => {
|
resolve: (state, events, caster) => {
|
||||||
for (const opp of state.players) {
|
for (const opp of state.players) {
|
||||||
if (!opp.alive || opp.id === caster.id) continue;
|
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;
|
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
|
||||||
retreatFromSight(state, events, opp, caster.position);
|
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);
|
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||||
if (!creature) return "no such monster";
|
if (!creature) return "no such monster";
|
||||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no 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";
|
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||||
else creature.maxDamage *= 2;
|
else creature.maxDamage *= 2;
|
||||||
@@ -1882,7 +1901,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to glue down";
|
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;
|
state.gluedCells[key] = true;
|
||||||
// "a duration equal to twice the NUMBER card played"
|
// "a duration equal to twice the NUMBER card played"
|
||||||
const turns = magnitude.duration * 2;
|
const turns = magnitude.duration * 2;
|
||||||
@@ -1906,7 +1925,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to lock up";
|
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 };
|
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||||
return null;
|
return null;
|
||||||
@@ -1924,7 +1943,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ka === kb) return "pick two different squares";
|
if (ka === kb) return "pick two different squares";
|
||||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
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 (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 itemsA = state.groundObjects[ka] ?? [];
|
||||||
const itemsB = state.groundObjects[kb] ?? [];
|
const itemsB = state.groundObjects[kb] ?? [];
|
||||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
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") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
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];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
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."
|
// "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);
|
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 || !other.alive) return "no such living player";
|
||||||
if (other.id === caster.id) return "that is already your home";
|
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) =>
|
const onHome = (home: Cell) =>
|
||||||
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
|
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
|
||||||
if (onHome(caster.home) !== onHome(other.home)) {
|
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)) {
|
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||||
return "the warp shimmers there — nothing can form on it";
|
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;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3370,8 +3389,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
|||||||
const d = seen.get(cellKey(cur))!;
|
const d = seen.get(cellKey(cur))!;
|
||||||
if (d >= 8) break; // teleport range is 4; stop early
|
if (d >= 8) break; // teleport range is 4; stop early
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
const n = neighbor(cur, side);
|
let n = neighbor(cur, side);
|
||||||
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue;
|
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);
|
seen.set(cellKey(n), d + 1);
|
||||||
if (cellKey(n) === cellKey(to)) return d + 1;
|
if (cellKey(n) === cellKey(to)) return d + 1;
|
||||||
queue.push(n);
|
queue.push(n);
|
||||||
@@ -3384,6 +3412,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
|||||||
// Setup
|
// Setup
|
||||||
|
|
||||||
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
||||||
|
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
|
||||||
const n = config.playerIds.length;
|
const n = config.playerIds.length;
|
||||||
let rng = createRng(config.seed);
|
let rng = createRng(config.seed);
|
||||||
const events: GameEvent[] = [];
|
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;
|
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
||||||
const d = dreadDistance(board, other.position, to);
|
const d = dreadDistance(board, other.position, to);
|
||||||
const dBefore = dreadDistance(board, other.position, from);
|
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;
|
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. */
|
/** 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 {
|
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
|
||||||
state.turn.movementUsed++;
|
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.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");
|
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
||||||
const events: GameEvent[] = [];
|
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) &&
|
if (!isAdjacentToEdge(p.position, cell, side) &&
|
||||||
!losToEdge(board, p.position, cell, side)) {
|
!losToEdge(board, p.position, cell, side)) {
|
||||||
return err("you cannot see that wall from here");
|
return err("you cannot see that wall from here");
|
||||||
@@ -5089,8 +5167,8 @@ function checkAmbushes(
|
|||||||
sprung = context.pickedUpTreasure === true;
|
sprung = context.pickedUpTreasure === true;
|
||||||
} else if (context.movedFrom) {
|
} else if (context.movedFrom) {
|
||||||
if (ambush.trigger.kind === "los") {
|
if (ambush.trigger.kind === "los") {
|
||||||
const before = gameLos(state, owner.position, context.movedFrom);
|
const before = gameLos(state, owner.position, context.movedFrom, owner.id);
|
||||||
const now = gameLos(state, owner.position, actor.position);
|
const now = gameLos(state, owner.position, actor.position, owner.id);
|
||||||
sprung = now && !before;
|
sprung = now && !before;
|
||||||
} else if (ambush.trigger.kind === "near") {
|
} else if (ambush.trigger.kind === "near") {
|
||||||
const dist = (c: Cell) =>
|
const dist = (c: Cell) =>
|
||||||
@@ -5102,7 +5180,7 @@ function checkAmbushes(
|
|||||||
|
|
||||||
// The committed spell must be legal right now, or the ambush stays armed.
|
// The committed spell must be legal right now, or the ambush stays armed.
|
||||||
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
|
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.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
|
||||||
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
|
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
|
// The server sends this after every state change; clients never see the
|
||||||
// deck order or other players' hands.
|
// 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 { cardDef, type CardInstance } from "./cards";
|
||||||
import {
|
import {
|
||||||
boardView,
|
boardView,
|
||||||
@@ -62,6 +62,8 @@ export interface GameView {
|
|||||||
/** Safes standing open (their combination entered this turn). */
|
/** Safes standing open (their combination entered this turn). */
|
||||||
openSafes: string[];
|
openSafes: string[];
|
||||||
groundObjects: Record<string, CardInstance[]>;
|
groundObjects: Record<string, CardInstance[]>;
|
||||||
|
/** The rules revision this game was dealt under. */
|
||||||
|
deckRev: number;
|
||||||
doorStates: Record<string, "jammed" | "removed">;
|
doorStates: Record<string, "jammed" | "removed">;
|
||||||
/** Accumulated attack damage per edge (public — cracks show). */
|
/** Accumulated attack damage per edge (public — cracks show). */
|
||||||
wallDamage: Record<string, number>;
|
wallDamage: Record<string, number>;
|
||||||
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
groundObjects: Object.fromEntries(
|
groundObjects: Object.fromEntries(
|
||||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||||
),
|
),
|
||||||
|
deckRev: state.config.deckRev ?? 1,
|
||||||
doorStates: { ...state.doorStates },
|
doorStates: { ...state.doorStates },
|
||||||
wallDamage: { ...state.wallDamage },
|
wallDamage: { ...state.wallDamage },
|
||||||
openDoorEdges: [...state.openDoorEdges],
|
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. */
|
/** 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> } {
|
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;
|
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 };
|
const edges = { ...board.edges };
|
||||||
for (const k of view.heldDoorEdges) delete edges[k];
|
for (const k of openKeys) delete edges[k];
|
||||||
board = { ...board, edges };
|
board = { ...board, edges };
|
||||||
}
|
}
|
||||||
const blockers: Record<string, true> = {};
|
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", () => {
|
describe("teleport as a counteraction", () => {
|
||||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
|
|||||||
@@ -720,4 +720,35 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) expect(r.error).toContain("dread");
|
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);
|
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();
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
const pick = giveCard(state, holder, "pick-lock");
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
state = must(state, holder, {
|
state = must(state, holder, {
|
||||||
@@ -453,9 +453,56 @@ describe("a held door is an open doorway to the eye", () => {
|
|||||||
target: { kind: "edge", cell, side },
|
target: { kind: "edge", cell, side },
|
||||||
});
|
});
|
||||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
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 },
|
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);
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type GameView,
|
type GameView,
|
||||||
type PlayerId,
|
type PlayerId,
|
||||||
|
CURRENT_RULES_REV,
|
||||||
} from "@wizwar/engine";
|
} from "@wizwar/engine";
|
||||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||||
import { recordRoom } from "./stats";
|
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).
|
/** 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;
|
* 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. */
|
* 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";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user