Files
wizwar6e/packages/engine/src/view.ts
T
Eric WagonerandClaude Fable 5 3308850bb6 Credibility pass: sweep the workshop floor
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>
2026-08-16 20:20:37 -04:00

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;
}