/** * What the player knows about the map, as distinct from what is true. * * The brief insists the player can never be certain a place is as they left it. * So heat is never read directly by the UI — the board reads *this*, a snapshot * of what was last observed and how long ago. Roads you have driven are known * but ageing; roads you have not are blank. */ import { levelFor, type HeatLevel, type HeatState } from './heat'; import { CONTROLS, type Control } from './regions'; import type { RoadNetwork } from './roads'; /** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */ export const CELL_SIZE = 24; export interface Intel { /** Heat value at the moment each segment was last observed. */ rememberedHeat: number[]; /** Level at the moment each segment was last observed. */ rememberedLevel: HeatLevel[]; /** Elapsed time when each segment was last observed; -1 for never. */ seenAt: number[]; /** Target nodes the player has actually reached. */ visitedNodes: Set; /** Ground actually laid eyes on, as a coarse grid. 1 = explored. */ explored: Uint8Array; /** * Who held each cell *when the player last stood in it*, as an index into * CONTROLS. The front moves on its own, so this is the only honest thing the * map can show: the player is never told a region changed hands behind them. */ rememberedControl: Uint8Array; cellsPerSide: number; /** World coordinate of the grid's lower corner. */ gridOrigin: number; } export function createIntel(roads: RoadNetwork, extent: number): Intel { // Cover more than the road network reaches, since node jitter pushes the // outermost junctions past `extent`. const gridOrigin = -(extent + 60); const cellsPerSide = Math.ceil((-gridOrigin * 2) / CELL_SIZE); return { rememberedHeat: new Array(roads.segments.length).fill(0), rememberedLevel: new Array(roads.segments.length).fill('clear'), seenAt: new Array(roads.segments.length).fill(-1), visitedNodes: new Set(), explored: new Uint8Array(cellsPerSide * cellsPerSide), rememberedControl: new Uint8Array(cellsPerSide * cellsPerSide), cellsPerSide, gridOrigin, }; } export const cellIndex = (intel: Intel, x: number, z: number): number | null => { const col = Math.floor((x - intel.gridOrigin) / CELL_SIZE); const row = Math.floor((z - intel.gridOrigin) / CELL_SIZE); if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) return null; return row * intel.cellsPerSide + col; }; export const isExplored = (intel: Intel, x: number, z: number): boolean => { const index = cellIndex(intel, x, z); return index !== null && intel.explored[index] === 1; }; /** * Marks everything within `radius` of a point as seen, recording who holds it * right now. Re-revealing ground you are standing in refreshes that record — * which is exactly how the player finds out the line has moved: by going back. * * Returns the count of cells that were blank before. */ export function reveal( intel: Intel, x: number, z: number, radius: number, controlOf?: (x: number, z: number) => Control, ): number { let found = 0; const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE); const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE); const minRow = Math.floor((z - radius - intel.gridOrigin) / CELL_SIZE); const maxRow = Math.ceil((z + radius - intel.gridOrigin) / CELL_SIZE); for (let row = minRow; row <= maxRow; row++) { for (let col = min; col <= max; col++) { if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) continue; const cx = intel.gridOrigin + (col + 0.5) * CELL_SIZE; const cz = intel.gridOrigin + (row + 0.5) * CELL_SIZE; if (Math.hypot(cx - x, cz - z) > radius) continue; const index = row * intel.cellsPerSide + col; if (controlOf) intel.rememberedControl[index] = CONTROLS.indexOf(controlOf(cx, cz)); if (intel.explored[index] === 1) continue; intel.explored[index] = 1; found++; } } return found; } export const hasSeen = (intel: Intel, segmentId: number): boolean => intel.seenAt[segmentId]! >= 0; /** Record what a segment looks like right now. */ export function observe(intel: Intel, heat: HeatState, segmentId: number, now: number): void { intel.rememberedHeat[segmentId] = heat.value[segmentId]!; intel.rememberedLevel[segmentId] = heat.level[segmentId]!; intel.seenAt[segmentId] = now; } /** * A recon sweep: everything within `radius` of the target is written down. * This is the mechanism the brief describes for turning a new target into a * known one without having to drive every road there first. */ export function survey( intel: Intel, heat: HeatState, roads: RoadNetwork, x: number, z: number, now: number, radius = 130, controlOf?: (x: number, z: number) => Control, ): number { let count = 0; for (const s of roads.segments) { const near = Math.hypot(s.ax - x, s.az - z) < radius || Math.hypot(s.bx - x, s.bz - z) < radius; if (!near) continue; observe(intel, heat, s.id, now); count++; } // A survey fills in the ground too, not just the roads through it. reveal(intel, x, z, radius, controlOf); return count; } export interface RouteIntel { /** Worst level remembered anywhere on the route. */ worst: HeatLevel; /** Segments on the route never observed at all. */ unknownCount: number; /** Seconds since the freshest observation went stale, or null if all unknown. */ stalest: number | null; } /** * Summarises a route the way a briefing would: the worst thing anyone has seen * on it, how much of it nobody has looked at, and how old that word is. */ export function summariseRoute(intel: Intel, segments: number[], now: number): RouteIntel { let worstRank = 0; let unknownCount = 0; let stalest: number | null = null; for (const id of segments) { if (!hasSeen(intel, id)) { unknownCount++; continue; } // Judge by the remembered value, not the remembered label, so a road left // just under a threshold does not read as safer than one just over it. const rank = ['clear', 'patrol', 'barricade', 'turret'].indexOf( levelFor(intel.rememberedHeat[id]!, intel.rememberedLevel[id]!), ); worstRank = Math.max(worstRank, rank); const age = now - intel.seenAt[id]!; stalest = stalest === null ? age : Math.max(stalest, age); } return { worst: (['clear', 'patrol', 'barricade', 'turret'] as const)[worstRank]!, unknownCount, stalest, }; }