// 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; groundObjects: Record; doorStates: Record; /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; 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; /** 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 | 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 { const out = new Set(); const me = view.players.find((p) => p.id === view.you); if (!me) return out; const blockers: Record = {}; 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 | 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(); 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(); 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(); const dist = new Map([[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(); 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; }