// 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; /** Board with dynamic wall changes already merged in. */ board: AssembledBoard; players: PlayerPublicView[]; yourHand: CardInstance[]; treasures: TreasureState[]; deckCount: number; discardCount: number; /** 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; /** 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, treasures: state.treasures.map((t) => ({ ...t })), deckCount: state.deck.length, discardCount: state.discard.length, 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, 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; }