// 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; /** Cells whose contents are glued down (public: the cast was seen). */ gluedCells: Record; /** Safes standing open (their combination entered this turn). */ openSafes: string[]; groundObjects: Record; /** The rules revision this game was dealt under. */ deckRev: number; doorStates: Record; /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; 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; 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; /** 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 | 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 = {}; 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 { const out = new Set(); 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 } { // 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 = {}; 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 | 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 === "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(); 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; }