// 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 { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { cardDef, type CardInstance } from "./cards"; import { boardView, edgeReentry, LOS_BLOCKING_CONTENT, type AmbushState, type CastStack, type GameEvent, type PushPending, type CreatureState, type GameState, type PlayerId, type SquareContent, type SustainedEffect, type TreasureState, type TurnState, dreadDistance, } 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; /** Down a pit, until a roll climbs them out — everyone saw the fall. */ inPit: boolean; 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; /** Spells lodged in slime, oldest first (public: each cast was seen). */ slimeTraps: 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; pushPending: PushPending | null; slowDeathPending: { playerId: PlayerId; points: number } | 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, inPit: p.inPit, 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 }, slimeTraps: Object.fromEntries( Object.entries(state.slimeTraps).map(([k, traps]) => [k, traps.map((t) => ({ cardId: t.card.cardId, casterId: t.casterId }))]), ), 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, pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null, slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : 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 square inside a living FEAR-bearer's aura — dreadDistance walks * through warps, the engine's own yardstick, so the painted aura and the * movement refusal can never disagree. */ export function fearCells(view: GameView): Set { const out = new Set(); for (const fp of view.players) { if (!fp.alive) continue; if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue; for (const k of Object.keys(view.board.cells)) { const [x, y] = k.split(",").map(Number) as [number, number]; if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k); } } return out; } /** * 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, from?: Cell): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); if (!me) return out; // Sight is taken from the viewer's square unless a cast leaves from // elsewhere — an ALTER EGO's — in which case it is the double's. const eye = from ?? me.position; const { board, blockers } = sightBasis(view); // In a dust cloud a wizard sees only their own square (rev 19). if (inDust(view, eye)) { out.add(`${eye.x},${eye.y}`); return out; } for (const key of Object.keys(board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; if (dustAtEnd(view, eye, { x, y })) continue; if (sightBetween(board, eye, { 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, eye, { x, y }, blockers)) { out.add(unseen[i]!); unseen.splice(i, 1); } } } } return out; } /** * Sight with an AROUND THE CORNER attached: every square straightly sighted, * plus every square visible from one of those — the caster looks to a middle * cell and the spell turns there, mirroring the engine's bentLos. */ export function bentSightedCellsFor(view: GameView, from?: Cell): Set { const out = sightedCellsFor(view, from); const me = view.players.find((p) => p.id === view.you); if (!me) return out; const mids = [...out].map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; }); const { board, blockers } = sightBasis(view); for (const key of Object.keys(board.cells)) { if (out.has(key)) continue; const [x, y] = key.split(",").map(Number) as [number, number]; const cell = { x, y }; if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) || bentSightThroughGap(board, me.position, cell, blockers)) { out.add(key); } } 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, ignoreDust = false): SightTrace | null { if (!ignoreDust && dustAtEnd(view, from, to)) return null; const { board, blockers } = sightBasis(view); return traceSight(board, from, to, blockers); } /** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */ function inDust(view: GameView, cell: Cell): boolean { return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust"; } /** Dust at either end kills a sight line; a square sees itself still. */ function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean { if (from.x === to.x && from.y === to.y) return false; return inDust(view, from) || inDust(view, to); } /** * 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 { if (dustAtEnd(view, from, to)) return false; 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). */ /** A sight line as the board draws it: the legs, the corner bend, and the * one wall a VISIONSTONE dissolved. */ export interface SightLine { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace }; /** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */ pierced?: string; } /** The sight line from one square to another under the sight law the * engine applies to a cast: a plain line (warps included), else the * VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs. * `afterTheFact` traces a cast already resolved, whose own dust cloud * would otherwise blind the line to it. */ export function sightTraceBetween( view: GameView, from: Cell, to: Cell, opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {}, ): SightLine | null { if (from.x === to.x && from.y === to.y) return null; const trace = traceSightFor(view, from, to, opts.afterTheFact); if (trace) return { from, to, trace }; // VISIONSTONE: the bearer sees through exactly one wall or door. When no // plain line exists, find the single edge whose removal opens one and draw // the ray straight through it, marking the pierced wall for the table. if (opts.visionstone) { const { board, blockers } = sightBasis(view); for (const [key, edge] of Object.entries(board.edges)) { if (edge === "open") continue; const edges = { ...board.edges }; delete edges[key]; const t = traceSight({ ...board, edges }, from, to, blockers); if (t) return { from, to, trace: t, pierced: key }; } } // 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 (opts.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 === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue; const leg1 = traceSightFor(view, from, mid, opts.afterTheFact); if (!leg1) continue; const leg2 = traceSightFor(view, mid, to, opts.afterTheFact); if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } }; } } return null; } /** The sight line of the attack on the stack, for the board to draw. */ export function stackSightTrace(view: GameView): SightLine | 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; return sightTraceBetween(view, a.position, d.position, { visionstone: a.displayed.some((c) => c.cardId === "visionstone"), bentCorner: stack.bentCorner === true, }); } /** The sight line a resolved cast was accepted on — a creation, a curse, * anything aimed by line of sight that never waited on the stack — so the * table can see how the aim was legal. `events` is the cast's own batch, * which names an AROUND THE CORNER cast. */ export function castSightTrace( view: GameView, cast: Extract, events: readonly GameEvent[], afterTheFact = false, ): SightLine | null { let def; try { def = cardDef(cast.cardId); } catch { return null; } if (def.los !== true) return null; const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null; if (!to) return null; const caster = view.players.find((p) => p.id === cast.caster); return sightTraceBetween(view, cast.from, to, { visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false, bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster), afterTheFact, }); } 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, bentCorner = false, from?: Cell): 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 = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from); 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 — // the maze wraps for teleporters as it does for walkers, a warp mouth // being one step like any doorway. 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 side of SIDES) { let n = neighbor(cur, side); if (!view.board.cells[key(n.x, n.y)]) { const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side); if (w) n = w.to.cell; else if (view.deckRev >= 22) { // Rev 22: the outer edge is no more than a wall to a teleporter. const back = edgeReentry(view.board, cur, side); if (!back) continue; n = back; } else continue; } const nk = key(n.x, n.y); if (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; }