A teleporter ignores walls, and the outer edge is no more than a wall to it: a line leaving the maze at any square's edge re-enters at the opposite edge on the same row or column, one space on. The lettered openings connect as they always did. Older games crossed the edge only at the openings, and replay so; U3U2, dealt at rev 21 with no teleport yet cast, was re-stamped to 22 at its table's request. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
601 lines
25 KiB
TypeScript
601 lines
25 KiB
TypeScript
// Per-player projection of GameState: everything public, plus YOUR hand.
|
|
// The server sends this after every state change; clients never see the
|
|
// deck order or other players' hands.
|
|
|
|
import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
|
import { cardDef, type CardInstance } from "./cards";
|
|
import {
|
|
boardView,
|
|
edgeReentry,
|
|
LOS_BLOCKING_CONTENT,
|
|
type AmbushState,
|
|
type CastStack,
|
|
type GameEvent,
|
|
type PushPending,
|
|
type CreatureState,
|
|
type GameState,
|
|
type PlayerId,
|
|
type SquareContent,
|
|
type SustainedEffect,
|
|
type TreasureState,
|
|
type TurnState,
|
|
dreadDistance,
|
|
} from "./game";
|
|
|
|
export interface PlayerPublicView {
|
|
id: PlayerId;
|
|
position: { x: number; y: number };
|
|
home: { x: number; y: number };
|
|
life: number;
|
|
alive: boolean;
|
|
handCount: number;
|
|
carriedTreasureId: string | null;
|
|
lostTurns: number;
|
|
extraTurns: number;
|
|
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
|
passWallCharges: number;
|
|
/** Down a pit, until a roll climbs them out — everyone saw the fall. */
|
|
inPit: boolean;
|
|
displayed: CardInstance[];
|
|
/** Which of the six physical wizard colors this player plays. */
|
|
colorIndex: number;
|
|
}
|
|
|
|
export interface GameView {
|
|
you: PlayerId;
|
|
phase: GameState["phase"];
|
|
winner: PlayerId | null;
|
|
winReason: "treasures" | "lastStanding" | null;
|
|
turn: TurnState;
|
|
activePlayerId: PlayerId;
|
|
/** Board with dynamic wall changes already merged in. */
|
|
board: AssembledBoard;
|
|
players: PlayerPublicView[];
|
|
yourHand: CardInstance[];
|
|
treasures: TreasureState[];
|
|
deckCount: number;
|
|
discardCount: number;
|
|
/** The discard pile, face-up as at the table (last = most recent). */
|
|
discardPile: CardInstance[];
|
|
/** Cards on the stack are face-up: the whole exchange is public. */
|
|
stack: CastStack | null;
|
|
pendingDiscard: PlayerId | null;
|
|
/** Duration spells in play (public knowledge). */
|
|
sustained: SustainedEffect[];
|
|
squareContents: Record<string, SquareContent>;
|
|
/** Spells lodged in slime, oldest first (public: each cast was seen). */
|
|
slimeTraps: Record<string, { cardId: string; casterId: PlayerId }[]>;
|
|
/** Cells whose contents are glued down (public: the cast was seen). */
|
|
gluedCells: Record<string, true>;
|
|
/** 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>;
|
|
openDoorEdges: string[];
|
|
/** Door edges held open by a standing wizard. */
|
|
heldDoorEdges: string[];
|
|
/** Edges conjured into being (walls, doors, firewalls) - dispellable. */
|
|
createdEdges: string[];
|
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
|
knownIllusionEdges: string[];
|
|
/** Every illusion edge and YOUR verdict on it: untested
|
|
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
|
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
|
creatures: CreatureState[];
|
|
/** Charges left on displayed wands (public), by card instance id. */
|
|
wandCharges: Record<string, number>;
|
|
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
|
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
|
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
|
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
|
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
|
/** A grab hangs while the treasure's owner decides their Ward. */
|
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
|
pushPending: PushPending | null;
|
|
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
|
yourAmbushes: AmbushState[];
|
|
/** Once the game is finished, every hand goes face-up on the table. */
|
|
revealedHands: Record<PlayerId, CardInstance[]> | null;
|
|
}
|
|
|
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|
const you = state.players.find((p) => p.id === playerId);
|
|
// Illusion walls render as real walls unless this viewer knows better.
|
|
// Their locations are open knowledge (the cast is public at a table) —
|
|
// what stays personal is each player's verdict.
|
|
const base = boardView(state);
|
|
const knownIllusionEdges: string[] = [];
|
|
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
|
let edges = base.edges;
|
|
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
|
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
|
illusionEdges[key] =
|
|
wall.createdBy === playerId ? "mine"
|
|
: wall.belief[playerId] ?? "untested";
|
|
if (knows) {
|
|
knownIllusionEdges.push(key);
|
|
} else {
|
|
if (edges === base.edges) edges = { ...base.edges };
|
|
edges[key] = "wall";
|
|
}
|
|
}
|
|
const board = edges === base.edges ? base : { ...base, edges };
|
|
return {
|
|
you: playerId,
|
|
phase: state.phase,
|
|
winner: state.winner,
|
|
winReason: state.winReason,
|
|
turn: state.turn,
|
|
activePlayerId: state.players[state.turn.activeIndex]!.id,
|
|
board,
|
|
players: state.players.map((p, i) => ({
|
|
id: p.id,
|
|
colorIndex: state.config.colors?.[i] ?? i,
|
|
position: p.position,
|
|
home: p.home,
|
|
life: p.life,
|
|
inPit: p.inPit,
|
|
alive: p.alive,
|
|
handCount: p.hand.length,
|
|
carriedTreasureId: p.carriedTreasureId,
|
|
lostTurns: p.lostTurns,
|
|
extraTurns: p.extraTurns,
|
|
passWallCharges: p.passWallCharges,
|
|
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
|
|
})),
|
|
yourHand: you ? [...you.hand] : [],
|
|
revealedHands: state.phase === "finished"
|
|
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
|
: null,
|
|
treasures: state.treasures.map((t) => ({ ...t })),
|
|
deckCount: state.deck.length,
|
|
discardCount: state.discard.length,
|
|
discardPile: [...state.discard],
|
|
stack: state.stack,
|
|
pendingDiscard: state.pendingDiscard,
|
|
sustained: state.sustained.map((s) => ({ ...s })),
|
|
squareContents: { ...state.squareContents },
|
|
slimeTraps: Object.fromEntries(
|
|
Object.entries(state.slimeTraps).map(([k, traps]) => [k, traps.map((t) => ({ cardId: t.card.cardId, casterId: t.casterId }))]),
|
|
),
|
|
gluedCells: { ...state.gluedCells },
|
|
openSafes: [...state.openSafes],
|
|
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],
|
|
heldDoorEdges: state.heldDoors.map((h) => h.key),
|
|
createdEdges: Object.keys(state.createdEdges),
|
|
knownIllusionEdges,
|
|
illusionEdges,
|
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
|
wandCharges: { ...state.wandCharges },
|
|
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
|
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
|
chaosPending: state.chaosPending
|
|
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
|
: null,
|
|
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
|
pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null,
|
|
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
|
|
yourAmbushes: state.ambushes
|
|
.filter((a) => a.ownerId === playerId)
|
|
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
|
boobytraps: state.boobytraps.map((t) => {
|
|
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
|
return {
|
|
casterId: t.casterId,
|
|
cells: t.cells.map((c) => ({ ...c })),
|
|
realCell: t.casterId === playerId ? { x: rx, y: ry } : null,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Every square inside a living FEAR-bearer's aura — dreadDistance walks
|
|
* through warps, the engine's own yardstick, so the painted aura and the
|
|
* movement refusal can never disagree. */
|
|
export function fearCells(view: GameView): Set<string> {
|
|
const out = new Set<string>();
|
|
for (const fp of view.players) {
|
|
if (!fp.alive) continue;
|
|
if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue;
|
|
for (const k of Object.keys(view.board.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Every cell the viewing player can see from where they stand, by the same
|
|
* line-of-sight rules the engine enforces — computed from the VIEW, so it
|
|
* reflects what this player knows (illusion walls they believe in block it).
|
|
* The basis for the client's "dim the ineligible squares" targeting aid.
|
|
*/
|
|
export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
|
const out = new Set<string>();
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
// Sight is taken from the viewer's square unless a cast leaves from
|
|
// elsewhere — an ALTER EGO's — in which case it is the double's.
|
|
const eye = from ?? me.position;
|
|
const { board, blockers } = sightBasis(view);
|
|
// In a dust cloud a wizard sees only their own square (rev 19).
|
|
if (inDust(view, eye)) {
|
|
out.add(`${eye.x},${eye.y}`);
|
|
return out;
|
|
}
|
|
for (const key of Object.keys(board.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (dustAtEnd(view, eye, { x, y })) continue;
|
|
if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
|
|
}
|
|
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
|
// any one — so a square is also sighted if removing a single edge
|
|
// reveals it, mirroring the engine's casterLos.
|
|
if (me.displayed.some((c) => c.cardId === "visionstone")) {
|
|
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
|
|
for (const edgeK of Object.keys(board.edges)) {
|
|
if (unseen.length === 0) break;
|
|
if ((board.edges[edgeK] ?? "open") === "open") continue;
|
|
const edges = { ...board.edges };
|
|
delete edges[edgeK];
|
|
const opened = { ...board, edges };
|
|
for (let i = unseen.length - 1; i >= 0; i--) {
|
|
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
|
|
if (sightBetween(opened, eye, { x, y }, blockers)) {
|
|
out.add(unseen[i]!);
|
|
unseen.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Sight with an AROUND THE CORNER attached: every square straightly sighted,
|
|
* plus every square visible from one of those — the caster looks to a middle
|
|
* cell and the spell turns there, mirroring the engine's bentLos.
|
|
*/
|
|
export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
|
const out = sightedCellsFor(view, from);
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
const mids = [...out].map((k) => {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
return { x, y };
|
|
});
|
|
const { board, blockers } = sightBasis(view);
|
|
for (const key of Object.keys(board.cells)) {
|
|
if (out.has(key)) continue;
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) ||
|
|
bentSightThroughGap(board, me.position, cell, blockers)) {
|
|
out.add(key);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 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 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;
|
|
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 openKeys) delete edges[k];
|
|
board = { ...board, edges };
|
|
}
|
|
const blockers: Record<string, true> = {};
|
|
for (const [key, content] of Object.entries(view.squareContents)) {
|
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
|
}
|
|
for (const p of view.players) {
|
|
if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) {
|
|
blockers[`${p.position.x},${p.position.y}`] = true;
|
|
}
|
|
}
|
|
return { board, blockers };
|
|
}
|
|
|
|
/**
|
|
* How one square sees another under this viewer's knowledge of the board
|
|
* (believed illusion walls block; held doors admit). Null when no sight
|
|
* exists — the renderer's material for drawing the line an attack traveled.
|
|
*/
|
|
export function traceSightFor(view: GameView, from: Cell, to: Cell, ignoreDust = false): SightTrace | null {
|
|
if (!ignoreDust && dustAtEnd(view, from, to)) return null;
|
|
const { board, blockers } = sightBasis(view);
|
|
return traceSight(board, from, to, blockers);
|
|
}
|
|
|
|
/** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
|
|
function inDust(view: GameView, cell: Cell): boolean {
|
|
return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
|
|
}
|
|
|
|
/** Dust at either end kills a sight line; a square sees itself still. */
|
|
function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
|
|
if (from.x === to.x && from.y === to.y) return false;
|
|
return inDust(view, from) || inDust(view, to);
|
|
}
|
|
|
|
/**
|
|
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
|
* sees a middle square which sees the target — mirroring the engine's
|
|
* bentLos so a client can predict whether the modifier will land.
|
|
*/
|
|
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
|
if (dustAtEnd(view, from, to)) return false;
|
|
const { board, blockers } = sightBasis(view);
|
|
if (sightBetween(board, from, to, blockers)) return true;
|
|
for (const key of Object.keys(board.cells)) {
|
|
if (blockers[key]) continue;
|
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
|
const mid = { x: mx, y: my };
|
|
if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The sight line behind the attack currently on the stack — the board's
|
|
* answer to "how can he even see me?". Null when nothing should draw:
|
|
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
|
* defender sharing a square, or no sight under this viewer's knowledge
|
|
* (a believed illusion wall can honestly hide the line).
|
|
*/
|
|
/** A sight line as the board draws it: the legs, the corner bend, and the
|
|
* one wall a VISIONSTONE dissolved. */
|
|
export interface SightLine {
|
|
from: Cell;
|
|
to: Cell;
|
|
trace: SightTrace;
|
|
bend?: { mid: Cell; trace: SightTrace };
|
|
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
|
|
pierced?: string;
|
|
}
|
|
|
|
/** The sight line from one square to another under the sight law the
|
|
* engine applies to a cast: a plain line (warps included), else the
|
|
* VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs.
|
|
* `afterTheFact` traces a cast already resolved, whose own dust cloud
|
|
* would otherwise blind the line to it. */
|
|
export function sightTraceBetween(
|
|
view: GameView,
|
|
from: Cell,
|
|
to: Cell,
|
|
opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {},
|
|
): SightLine | null {
|
|
if (from.x === to.x && from.y === to.y) return null;
|
|
const trace = traceSightFor(view, from, to, opts.afterTheFact);
|
|
if (trace) return { from, to, trace };
|
|
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
|
// plain line exists, find the single edge whose removal opens one and draw
|
|
// the ray straight through it, marking the pierced wall for the table.
|
|
if (opts.visionstone) {
|
|
const { board, blockers } = sightBasis(view);
|
|
for (const [key, edge] of Object.entries(board.edges)) {
|
|
if (edge === "open") continue;
|
|
const edges = { ...board.edges };
|
|
delete edges[key];
|
|
const t = traceSight({ ...board, edges }, from, to, blockers);
|
|
if (t) return { from, to, trace: t, pierced: key };
|
|
}
|
|
}
|
|
// AROUND THE CORNER: no straight line exists — find a middle square both
|
|
// ends can see and draw the sight leg by leg, so the table can audit the
|
|
// needle instead of doubting it.
|
|
if (opts.bentCorner) {
|
|
for (const key of Object.keys(view.board.cells)) {
|
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
|
const mid = { x: mx, y: my };
|
|
if ((mid.x === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue;
|
|
const leg1 = traceSightFor(view, from, mid, opts.afterTheFact);
|
|
if (!leg1) continue;
|
|
const leg2 = traceSightFor(view, mid, to, opts.afterTheFact);
|
|
if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** The sight line of the attack on the stack, for the board to draw. */
|
|
export function stackSightTrace(view: GameView): SightLine | null {
|
|
const stack = view.stack;
|
|
if (!stack || stack.creatureId) return null;
|
|
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
|
const a = view.players.find((p) => p.id === stack.attackerId);
|
|
const d = view.players.find((p) => p.id === stack.defenderId);
|
|
if (!a || !d) return null;
|
|
return sightTraceBetween(view, a.position, d.position, {
|
|
visionstone: a.displayed.some((c) => c.cardId === "visionstone"),
|
|
bentCorner: stack.bentCorner === true,
|
|
});
|
|
}
|
|
|
|
/** The sight line a resolved cast was accepted on — a creation, a curse,
|
|
* anything aimed by line of sight that never waited on the stack — so the
|
|
* table can see how the aim was legal. `events` is the cast's own batch,
|
|
* which names an AROUND THE CORNER cast. */
|
|
export function castSightTrace(
|
|
view: GameView,
|
|
cast: Extract<GameEvent, { type: "spellCast" }>,
|
|
events: readonly GameEvent[],
|
|
afterTheFact = false,
|
|
): SightLine | null {
|
|
let def;
|
|
try { def = cardDef(cast.cardId); } catch { return null; }
|
|
if (def.los !== true) return null;
|
|
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
|
|
if (!to) return null;
|
|
const caster = view.players.find((p) => p.id === cast.caster);
|
|
return sightTraceBetween(view, cast.from, to, {
|
|
visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false,
|
|
bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster),
|
|
afterTheFact,
|
|
});
|
|
}
|
|
|
|
const CREATION_CARD_IDS = new Set([
|
|
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
|
|
"fill-square-with-slime", "create-pit", "handful-of-tacks",
|
|
]);
|
|
const SUMMON_CARD_IDS = new Set([
|
|
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
|
]);
|
|
|
|
/**
|
|
* Squares a cell-target card can legally aim at, for the client's dimming
|
|
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
|
* Null = this card's eligibility is not modeled; light everything.
|
|
*/
|
|
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return null;
|
|
const cells = Object.keys(view.board.cells);
|
|
const key = (x: number, y: number) => `${x},${y}`;
|
|
const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
|
|
|
|
if (CREATION_CARD_IDS.has(cardId)) {
|
|
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
|
// treasure, object, or a warp token — and in the caster's sight.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (view.squareContents[k]) continue;
|
|
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
|
|
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
|
|
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
|
|
if ((view.groundObjects[k] ?? []).length > 0) continue;
|
|
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (SUMMON_CARD_IDS.has(cardId)) {
|
|
// Summon: any sighted square free of content and creatures.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (view.squareContents[k]) continue;
|
|
if (view.creatures.some((c) => key(c.position.x, c.position.y) === k)) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "teleport") {
|
|
// Up to four spaces, walls and objects ignored; not into solid stone.
|
|
// BFS over existing cells, matching the engine's wallIgnoringDistance —
|
|
// the maze wraps for teleporters as it does for walkers, a warp mouth
|
|
// being one step like any doorway.
|
|
const out = new Set<string>();
|
|
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
|
const queue = [me.position];
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift()!;
|
|
const d = dist.get(key(cur.x, cur.y))!;
|
|
if (d >= 4) continue;
|
|
for (const side of SIDES) {
|
|
let n = neighbor(cur, side);
|
|
if (!view.board.cells[key(n.x, n.y)]) {
|
|
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
|
|
if (w) n = w.to.cell;
|
|
else if (view.deckRev >= 22) {
|
|
// Rev 22: the outer edge is no more than a wall to a teleporter.
|
|
const back = edgeReentry(view.board, cur, side);
|
|
if (!back) continue;
|
|
n = back;
|
|
} else continue;
|
|
}
|
|
const nk = key(n.x, n.y);
|
|
if (dist.has(nk)) continue;
|
|
dist.set(nk, d + 1);
|
|
queue.push(n);
|
|
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "boobytrap") {
|
|
// Tokens go anywhere on the board except solid stone — no sight needed;
|
|
// a trap you can see coming is a poor trap.
|
|
return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone"));
|
|
}
|
|
|
|
if (cardId === "glue" || cardId === "safe") {
|
|
// Both seize a square that HOLDS something — a loose object or an
|
|
// unclaimed treasure — within the caster's sight. The safe additionally
|
|
// needs the square clear of terrain to build around its prize.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (cardId === "safe" && view.squareContents[k]) continue;
|
|
const hasObject =
|
|
(view.groundObjects[k] ?? []).length > 0 ||
|
|
view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k);
|
|
if (!hasObject) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "thumb-of-god") {
|
|
// The die is aimed by sight; where it lands after drifting is fate's.
|
|
return sighted;
|
|
}
|
|
if (cardId === "stone-to-water") {
|
|
// The cast demands sight of the stone block, so only sighted ones light.
|
|
// The card equally targets stone WALLS — edges the cell-shadow cannot
|
|
// express — so with no stone square in view, dimming would shroud the
|
|
// true targets: light everything instead.
|
|
const out = new Set(
|
|
cells.filter((k) => view.squareContents[k]?.kind === "stone" && sighted.has(k)),
|
|
);
|
|
return out.size > 0 ? out : null;
|
|
}
|
|
if (cardId === "dispel-creation") {
|
|
// Anything created — terrain or creature, whoever made it — in sight.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
const created = view.squareContents[k] != null ||
|
|
view.creatures.some((c) => key(c.position.x, c.position.y) === k);
|
|
if (created && sighted.has(k)) out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
return null;
|
|
}
|