The objective beacon now goes out once the cargo is dropped or the survey is done. The target is no longer somewhere the player needs to be, and the bases' own beacons take over for the trip home. The front moves from two independent sources. Mission completions push it forward, weighted by what the job was worth, and that contribution decays over about seven minutes so ground won is not held for free. On top of that the line drifts on its own in both directions, which the brief is explicit about: ambient drift is what makes this a war rather than a level, and it is the only mechanism for the enemy's attention being pulled elsewhere. Drift is a sum of slow waves, not a random walk. A first attempt at a damped walk moved 3m in four minutes, because white noise scaled by dt averages itself out. Waves at incommensurate periods are unpredictable in practice, bounded by construction, and reproducible from the seed, so a test can assert that a region changed hands while the player was away. Most of the movement is shared across boundaries, since a front advances as a whole; independent per-band waves were squeezing bands into the ordering clamp. Crucially the map no longer reads live state. Intel now records who held each cell when the player last stood in it, and the minimap draws that, with the border inferred from where remembered control changes rather than from the front's true position. Otherwise the map would quietly hand the player a live readout of a line that is supposed to move behind their back. Stale roads are drawn faded, so untrustworthy notes look untrustworthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
6.5 KiB
TypeScript
179 lines
6.5 KiB
TypeScript
/**
|
|
* 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<number>;
|
|
/** 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,
|
|
};
|
|
}
|