Files
wizwar6e/packages/engine/src/view.ts
T
Eric WagonerandClaude Fable 5 9e4807eab8 The needle shows itself; floor objects answer a tap
Two findings from duel two (room UNS6). AROUND THE CORNER's bent
sight was legal but invisible: a 1-by-6 diagonal threading six open
edge spans read as an impossible shot because the table never saw the
line. The stack now records bentCorner, and stackSightTrace finds the
middle square and returns both legs; the board draws them with a
pulsing diamond on the corner the sight bent around — the answer to
"how can he even see me?" now covers the bent case.

Floor objects (a dropped wizardblade) showed a marker but answered
only a 450ms hold; a plain click did nothing. The marker itself is
now a click target that opens the same card peek.

All 21 ledgers verified; 284 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:38:57 -04:00

447 lines
18 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 { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, 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;
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
passWallCharges: 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;
/** 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>;
/** 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;
/** 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,
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 },
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,
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 { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { 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, me.position, { x, y }, blockers)) {
out.add(unseen[i]!);
unseen.splice(i, 1);
}
}
}
}
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): SightTrace | null {
const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers);
}
/**
* 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 {
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).
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | 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;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position);
if (trace) return { from: a.position, to: d.position, trace };
// 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 (stack.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 === a.position.x && mid.y === a.position.y) ||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
const leg1 = traceSightFor(view, a.position, mid);
if (!leg1) continue;
const leg2 = traceSightFor(view, mid, d.position);
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
}
}
return null;
}
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 === "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;
}