Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.
Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.
Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
11 KiB
TypeScript
291 lines
11 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 { sightBetween, type AssembledBoard } from "./board";
|
|
import { type CardInstance } from "./cards";
|
|
import {
|
|
boardView,
|
|
LOS_BLOCKING_CONTENT,
|
|
type AmbushState,
|
|
type CastStack,
|
|
type CreatureState,
|
|
type GameState,
|
|
type PlayerId,
|
|
type SquareContent,
|
|
type SustainedEffect,
|
|
type TreasureState,
|
|
type TurnState,
|
|
} 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;
|
|
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;
|
|
/** The game's rules revision — the client mirrors rev-gated legality. */
|
|
deckRev: number;
|
|
/** 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>;
|
|
groundObjects: Record<string, CardInstance[]>;
|
|
doorStates: Record<string, "jammed" | "removed">;
|
|
/** Accumulated attack damage per edge (public — cracks show). */
|
|
wallDamage: Record<string, number>;
|
|
openDoorEdges: string[];
|
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
|
knownIllusionEdges: string[];
|
|
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;
|
|
/** Whether YOUR ward is set to spring. */
|
|
yourWardArmed: boolean;
|
|
/** 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.
|
|
const base = boardView(state);
|
|
const knownIllusionEdges: string[] = [];
|
|
let edges = base.edges;
|
|
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
|
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
|
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,
|
|
alive: p.alive,
|
|
handCount: p.hand.length,
|
|
carriedTreasureId: p.carriedTreasureId,
|
|
lostTurns: p.lostTurns,
|
|
extraTurns: p.extraTurns,
|
|
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,
|
|
deckRev: state.config.deckRev ?? 1,
|
|
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 },
|
|
groundObjects: Object.fromEntries(
|
|
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
|
),
|
|
doorStates: { ...state.doorStates },
|
|
wallDamage: { ...state.wallDamage },
|
|
openDoorEdges: [...state.openDoorEdges],
|
|
knownIllusionEdges,
|
|
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,
|
|
yourWardArmed: state.wardArmed.includes(playerId),
|
|
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 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): Set<string> {
|
|
const out = new Set<string>();
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
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;
|
|
}
|
|
}
|
|
for (const key of Object.keys(view.board.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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): 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 = sightedCellsFor(view);
|
|
|
|
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.
|
|
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 [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
|
const n = { x: cur.x + dx, y: cur.y + dy };
|
|
const nk = key(n.x, n.y);
|
|
if (!view.board.cells[nk] || 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 === "stone-to-water") {
|
|
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
|
|
}
|
|
if (cardId === "dispel-creation") {
|
|
return new Set(cells.filter((k) => view.squareContents[k] != null));
|
|
}
|
|
return null;
|
|
}
|