/** * Everything in the world that moves and has a side. Pure — no engine imports. * * Up to now the world was scenery: heat put concrete on a road and that was the * whole of the enemy. This is the layer that makes it inhabited — traffic that * has somewhere to be, people on foot, patrols that are *dispatched* to a road * because of what you did to it, checkpoints that have to be built before they * exist, and two armies fighting each other whether or not you are watching. * * The player is not one of these. They are a car driving through it. */ import type { Rng } from '../core/rng'; import type { RoadNetwork, RoadSegment } from './roads'; import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads'; import { findRoute, travelTime, type Graph } from './routing'; import type { Control, Front } from './regions'; import { controlAt } from './regions'; export type Faction = 'enemy' | 'insurgent' | 'civilian'; export type UnitKind = 'car' | 'soldier'; export type Role = /** Civilian traffic with somewhere to be. */ | 'traffic' /** Civilians on foot, near buildings. */ | 'pedestrian' /** Dispatched to a road because that road got noticed. */ | 'patrol' /** Sent to build a checkpoint, then leaves. */ | 'engineer' /** Stands on a finished checkpoint and shoots. */ | 'garrison' /** Fighting the other army. */ | 'fighter' /** Your own side, holding the ground it holds. Thick at home, thin at the line. */ | 'militia'; export interface Unit { id: number; kind: UnitKind; faction: Faction; role: Role; x: number; z: number; heading: number; speed: number; hp: number; /** Remaining node ids to drive through. */ path: number[]; /** Seconds before this unit gives up and leaves. */ expires: number; /** Segment a patrol or engineer was sent to. */ assigned: number | null; /** Time spent doing the job at the assignment. */ onStation: number; /** Seconds until this unit can fire again. */ cooldown: number; /** Height of the muzzle, so a gunner on a tower shoots over the barricade. */ elevation: number; /** What an engineer was sent to put up. */ building?: BuildStage; /** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */ hunting?: boolean; /** Current speed while chasing, wound up from a standing start. */ chaseSpeed?: number; } /** * What can be put up on a road, in the order it goes up. * * Both stages are *work*. Concrete does not pour itself the instant a road * becomes notorious any more than a tower does — someone has to be sent, arrive, * and stand there while it goes up. */ export type BuildStage = 'barricade' | 'tower'; export interface Checkpoint { segment: number; /** What is going up right now. */ stage: BuildStage; /** 0..1 toward the current stage. */ progress: number; /** Stages finished and standing. */ done: BuildStage[]; x: number; z: number; } export const hasBuilt = (site: Checkpoint | undefined, stage: BuildStage): boolean => site?.done.includes(stage) ?? false; export interface UnitState { units: Unit[]; checkpoints: Map; /** Segments a patrol is already on the way to, so we do not send five. */ dispatched: Set; nextId: number; lastSkirmishAt: number; } export const createUnits = (): UnitState => ({ units: [], checkpoints: new Map(), dispatched: new Set(), nextId: 1, lastSkirmishAt: -999, }); // --- Tuning --------------------------------------------------------------- /** How many civilian vehicles try to exist near the player. */ export const TRAFFIC_TARGET = 34; export const PEDESTRIAN_TARGET = 30; /** Units further than this from the player are despawned; the world is big. */ export const SIM_RADIUS = 640; /** Civilians keep clear of the shooting. */ const CIVILIAN_FLEE_RANGE = 70; const SPEED: Record = { car: 14, soldier: 2.4 }; /** * How fast a car drives when it is chasing you rather than working. * * A patrol on its rounds does 14 m/s. A healthy player tops out around 29, so * at ordinary speed a chase was won by holding W until the 95m sight radius * did the rest — there was no decision in it anywhere. * * 21 m/s sits deliberately between the two: faster than anyone actually * averages through a grid of buildings and junctions, slower than the car's * top end on a clear run. So a long trunk straight is an escape and the lanes * are not, which puts the road hierarchy under real pressure for the first * time — the fast conspicuous road becomes the one you *want* when they are * behind you, and it is the one they will look on next time. */ const HUNT_SPEED = 21; /** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */ const HUNT_ACCELERATION = 7; /** * Headings a hunter will try, in order, when the straight line is blocked: * dead ahead, then further and further round either side of the obstruction. * Alternating sides means they take whichever way round is actually open * rather than committing to a direction and grinding along a wall. */ const HUNT_DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9]; /** Seconds a dispatched patrol works its road before leaving. */ const PATROL_DURATION = 90; /** Heat removed per second by a patrol actually driving its assigned road. */ export const PATROL_HEAT_DECAY = 0.005; /** * District heat a patrol settles per second, just by being present. * * An order of magnitude below the road figure, on purpose. A patrol re-secures * the road it was sent to; it barely touches how the district feels about you. * If this were anywhere near the road rate, sitting still while patrols came and * went would quietly launder your reputation, and the whole point of district * heat is that it is the thing you cannot patrol away. */ export const PATROL_AREA_DECAY = 0.0005; /** * Seconds of an engineer standing on site per stage. Concrete is quicker than a * tower, but neither is instant — arriving mid-build is meant to be a thing that * happens to you. */ const BUILD_SECONDS: Record = { barricade: 22, tower: 40 }; /** Minimum gap between skirmishes breaking out. */ const SKIRMISH_SPACING = 55; /** * Standing patrols the enemy keeps on the roads near the player, by whose * ground it is. * * These are not a reaction to anything the player did. Occupied territory is * patrolled because it is occupied, and the deeper in you go the more of it * there is. Heat-triggered patrols arrive on top of this. */ export const AMBIENT_PATROLS: Record = { liberated: 0, contested: 1, occupied: 3, frontier: 5, }; /** * Your own people on the ground, by whose ground it is. * * The mirror of AMBIENT_PATROLS, and deliberately the other way up. Liberated * territory read as empty — no patrols by design, and nobody else either — so * the safest part of the map was also the deadest, and coming home felt like * arriving nowhere rather than arriving somewhere held. It also made the * palette do all the work of telling the bands apart, when the population is * the more legible signal: who is standing in the street tells you whose street * it is. * * Thick at home and thinning fast, so the gradient itself is information. Past * the contested band there are none: that is what "past the line" means, and a * friendly face on the frontier would be a safety net the place is not supposed * to have. * * There is a second, quieter consequence. Pursuit calls a chase off when a * hunter has insurgents on its doorstep, so running for your own lines now * genuinely works — the closer you get to home the more likely they peel off. * That was already the rule; there was simply nobody around to trigger it. */ export const AMBIENT_ALLIES: Record = { liberated: 14, contested: 5, occupied: 0, frontier: 0, }; /** * How far from the player a militiaman is still worth simulating. * * Far tighter than SIM_RADIUS, and past the fog in every band, so they leave * out of sight rather than popping. They are ambience with no job to travel * to: keeping them to the general cull radius meant a crowd of friendly faces * followed the player half a kilometre into occupied ground. */ const MILITIA_RADIUS = 380; export const UNIT_HP: Record = { car: 60, soldier: 30 }; // --- Helpers -------------------------------------------------------------- const distance = (a: { x: number; z: number }, b: { x: number; z: number }) => Math.hypot(a.x - b.x, a.z - b.z); function makeUnit(state: UnitState, unit: Omit): Unit { const made = { ...unit, id: state.nextId++ }; state.units.push(made); return made; } const nodeById = (roads: RoadNetwork, id: number) => roads.nodes[id]!; /** Nearest node to a point, for putting a unit onto the network. */ function nearestNode(roads: RoadNetwork, x: number, z: number): number { let best = roads.nodes[0]!; let bestDistance = Infinity; for (const n of roads.nodes) { const d = Math.hypot(n.x - x, n.z - z); if (d < bestDistance) { bestDistance = d; best = n; } } return best.id; } /** Steps a unit along its path. Returns true when the path is exhausted. */ function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean { const next = unit.path[0]; if (next === undefined) return true; const node = nodeById(roads, next); const dx = node.x - unit.x; const dz = node.z - unit.z; const remaining = Math.hypot(dx, dz); if (remaining < 3) { unit.path.shift(); return unit.path.length === 0; } unit.heading = Math.atan2(dx, dz); const move = Math.min(remaining, unit.speed * dt); unit.x += (dx / remaining) * move; unit.z += (dz / remaining) * move; return false; } /** * Node list for a journey, *including* the node it starts from. * * Dropping the start node looked harmless and was not. A unit re-routing from * mid-segment is not standing on the node its route begins at, so its first leg * became a straight line to a node two hops away — across buildings, across * open country, wherever that line happened to go. Keeping the start node makes * a unit rejoin the road before setting off. Anything already standing on it * shifts past within a step. */ function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] { return findRoute(graph, from, to, travelTime(roads))?.nodes ?? []; } // --- Spawning ------------------------------------------------------------- /** * Civilian traffic. Picks somewhere to be and drives there, then picks * somewhere else. It exists to make the roads feel used by anyone other than * you — and, incidentally, to make a road with *no* traffic on it feel wrong. */ function spawnTraffic( state: UnitState, roads: RoadNetwork, graph: Graph, near: { x: number; z: number }, rng: Rng, ): void { const candidates = roads.nodes.filter((n) => { const d = distance(n, near); // Far enough to arrive rather than pop into view. return d > 120 && d < SIM_RADIUS; }); if (candidates.length < 2) return; const from = candidates[Math.floor(rng() * candidates.length)]!; const to = candidates[Math.floor(rng() * candidates.length)]!; const path = routeTo(graph, roads, from.id, to.id); if (path.length === 0) return; makeUnit(state, { kind: 'car', faction: 'civilian', role: 'traffic', x: from.x, z: from.z, heading: 0, speed: SPEED.car * (0.7 + rng() * 0.4), hp: UNIT_HP.car, path, expires: 600, assigned: null, onStation: 0, cooldown: 0, elevation: 1, }); } function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void { const angle = rng() * Math.PI * 2; const radius = 60 + rng() * 200; makeUnit(state, { kind: 'soldier', faction: 'civilian', role: 'pedestrian', x: near.x + Math.cos(angle) * radius, z: near.z + Math.sin(angle) * radius, heading: rng() * Math.PI * 2, speed: SPEED.soldier * (0.5 + rng() * 0.5), hp: UNIT_HP.soldier, path: [], expires: 400, assigned: null, onStation: 0, cooldown: 0, elevation: 1.2, }); } /** * The point of this whole file: heat on a road is no longer a thing that simply * appears. Something is *sent*, from somewhere, and it has to arrive. */ export function dispatchTo( state: UnitState, roads: RoadNetwork, graph: Graph, segment: RoadSegment, role: 'patrol' | 'engineer', rng: Rng, stage: BuildStage = 'barricade', ): Unit | null { if (state.dispatched.has(segment.id)) return null; // Come from a junction well behind the segment, deeper into enemy ground, so // the patrol arrives from somewhere plausible rather than materialising. const origins = roads.nodes.filter((n) => { const d = Math.hypot(n.x - segment.ax, n.z - segment.az); // Far enough to be somewhere else, close enough to arrive while it matters. return d > 150 && d < 430; }); if (origins.length === 0) return null; const origin = origins[Math.floor(rng() * origins.length)]!; const path = routeTo(graph, roads, origin.id, segment.a); if (path.length === 0) return null; state.dispatched.add(segment.id); return makeUnit(state, { kind: 'car', faction: 'enemy', role, x: origin.x, z: origin.z, heading: 0, speed: SPEED.car * (role === 'engineer' ? 0.8 : 1), hp: UNIT_HP.car, path, expires: 400, assigned: segment.id, onStation: 0, cooldown: 1, elevation: 1, building: role === 'engineer' ? stage : undefined, }); } /** * A patrol that is simply *there*, already working a road in enemy territory, * rather than one summoned by the player's own tracks. * * Spawned onto its road rather than driven in from a depot: hostile ground * should have patrols on it the moment you arrive, not thirty seconds later. */ function spawnAmbientPatrol( state: UnitState, roads: RoadNetwork, near: { x: number; z: number }, front: Front, rng: Rng, ): boolean { const candidates = roads.segments.filter((segment) => { if (state.dispatched.has(segment.id)) return false; const mid = { x: (segment.ax + segment.bx) / 2, z: (segment.az + segment.bz) / 2 }; const gap = distance(mid, near); // Not on top of the player, not beyond what is simulated. if (gap < 90 || gap > SIM_RADIUS) return false; return controlAt(front, mid.x, mid.z) !== 'liberated'; }); if (candidates.length === 0) return false; const segment = candidates[Math.floor(rng() * candidates.length)]!; const start = pointOnSegment(segment, rng(), 0); state.dispatched.add(segment.id); makeUnit(state, { kind: 'car', faction: 'enemy', role: 'patrol', x: start.x, z: start.z, heading: start.yaw, speed: SPEED.car, hp: UNIT_HP.car, path: [], expires: 260 + rng() * 200, assigned: segment.id, // Start part-way through a sweep, so they are not all in step. onStation: rng() * 20, cooldown: 1, elevation: 1, }); return true; } /** * One of yours, on foot, on ground your side holds. * * Placed by walking out from the player and checking who holds where they would * stand, rather than by trusting the band the player is in: near a border those * are different answers, and a militiaman standing fifty metres inside occupied * ground is exactly the thing that would make the front unreadable. */ function spawnAlly( state: UnitState, near: { x: number; z: number }, front: Front, rng: Rng, ): boolean { for (let attempt = 0; attempt < 8; attempt++) { const angle = rng() * Math.PI * 2; const radius = 70 + rng() * 230; const x = near.x + Math.cos(angle) * radius; const z = near.z + Math.sin(angle) * radius; const control = controlAt(front, x, z); if (control !== 'liberated' && control !== 'contested') continue; makeUnit(state, { kind: 'soldier', faction: 'insurgent', role: 'militia', x, z, heading: rng() * Math.PI * 2, speed: SPEED.soldier * (0.5 + rng() * 0.5), hp: UNIT_HP.soldier, path: [], expires: 400 + rng() * 300, assigned: null, onStation: 0, cooldown: rng(), elevation: 1.2, }); return true; } return false; } /** Two squads run into each other. The player is not invited. */ function spawnSkirmish( state: UnitState, roads: RoadNetwork, near: { x: number; z: number }, front: Front, rng: Rng, ): boolean { const candidates = roads.nodes.filter((n) => { const d = distance(n, near); if (d < 150 || d > SIM_RADIUS) return false; const control = controlAt(front, n.x, n.z); // Fights happen where the war is, not behind either side's lines. return control === 'contested' || control === 'occupied'; }); if (candidates.length === 0) return false; const at = candidates[Math.floor(rng() * candidates.length)]!; const separation = 34; const angle = rng() * Math.PI * 2; for (const [faction, side] of [ ['enemy', 1], ['insurgent', -1], ] as const) { const count = 2 + Math.floor(rng() * 3); for (let i = 0; i < count; i++) { makeUnit(state, { kind: 'soldier', faction, role: 'fighter', x: at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16, z: at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16, heading: angle + (side > 0 ? Math.PI : 0), speed: SPEED.soldier, hp: UNIT_HP.soldier, path: [], expires: 150 + rng() * 90, assigned: null, onStation: 0, cooldown: rng(), elevation: 1.2, }); } } return true; } // --- The step ------------------------------------------------------------- export interface UnitStep { dt: number; now: number; player: { x: number; z: number }; front: Front; /** Current heat level per segment, to decide what should be dispatched. */ heatLevel: (segmentId: number) => string; /** Called when a patrol is working a road, to cool it down. */ decayHeat: (segmentId: number, amount: number) => void; /** * Called for the district a patrol is standing in. Takes a position rather * than a district id so this file never has to know how areas are gridded. */ decayArea: (x: number, z: number, amount: number) => void; /** * Where hunting units should be heading: the player, or the last place they * were seen. Null when nobody is chasing. */ hunt: { x: number; z: number } | null; /** * Is this point inside a building? Hunters drive round them rather than * through them, which is the entire reason turning a corner works. */ blocked?: (x: number, z: number) => boolean; } export interface UnitEvents { /** Stages finished this step, so the world can put them up. */ built: Array<{ segment: number; stage: BuildStage }>; /** Checkpoints abandoned, so the world can take them away. */ removed: number[]; skirmish: boolean; } export function stepUnits( state: UnitState, step: UnitStep, roads: RoadNetwork, graph: Graph, rng: Rng, ): UnitEvents { const events: UnitEvents = { built: [], removed: [], skirmish: false }; const { dt, player } = step; // --- Population: only simulate what is near enough to matter --- const civilianCars = state.units.filter((u) => u.role === 'traffic').length; const pedestrians = state.units.filter((u) => u.role === 'pedestrian').length; if (civilianCars < TRAFFIC_TARGET && rng() < dt * 2) { spawnTraffic(state, roads, graph, player, rng); } if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) { spawnPedestrian(state, player, rng); } // --- Enemy ground is patrolled because it is enemy ground --- const here = controlAt(step.front, player.x, player.z); const patrols = state.units.filter((u) => u.role === 'patrol').length; if (patrols < AMBIENT_PATROLS[here] && rng() < dt * 1.5) { spawnAmbientPatrol(state, roads, player, step.front, rng); } // --- And your own ground has your own people standing in it --- const allies = state.units.filter((u) => u.role === 'militia').length; if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) { spawnAlly(state, player, step.front, rng); } // --- A war going on regardless of the player --- if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) { if (spawnSkirmish(state, roads, player, step.front, rng)) { state.lastSkirmishAt = step.now; events.skirmish = true; } } // --- Per-unit behaviour --- for (const unit of state.units) { unit.expires -= dt; // Chasing overrides the job. A patrol that has recognised you is no longer // working a road, and a hunt that politely waited its turn behind whatever // the unit was already doing would not be a hunt. if (unit.hunting && step.hunt) { const dx = step.hunt.x - unit.x; const dz = step.hunt.z - unit.z; const gap = Math.hypot(dx, dz); const wanted = Math.atan2(dx, dz); unit.heading = wanted; // Cars wind up to chase speed; people on foot chase at the pace they walk. const top = unit.kind === 'car' ? HUNT_SPEED : unit.speed; unit.chaseSpeed = Math.min(top, (unit.chaseSpeed ?? unit.speed) + HUNT_ACCELERATION * dt); // Hold off a little rather than piling into the car; they want to shoot // at it, and a ram is the player's move, not theirs. if (gap > 12) { const move = Math.min(gap, unit.chaseSpeed * dt); // Straight at the target if there is a way through, otherwise round the // side of whatever is in the way. Without this they drive through the // buildings, and a chase you cannot lose by turning a corner is not a // chase — it is a timer. for (const offset of HUNT_DETOURS) { const heading = wanted + offset; const x = unit.x + Math.sin(heading) * move; const z = unit.z + Math.cos(heading) * move; if (step.blocked?.(x, z)) continue; unit.x = x; unit.z = z; unit.heading = heading; break; } } // Being chased keeps a unit alive past its ordinary shift. unit.expires = Math.max(unit.expires, 30); continue; } // Off the chase, so back to whatever pace the job runs at. unit.chaseSpeed = undefined; switch (unit.role) { case 'traffic': { if (advance(unit, roads, dt)) { const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!; unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id); if (unit.path.length === 0) unit.expires = 0; } break; } case 'pedestrian': { // A slow wander. People are not going anywhere in particular. if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5; unit.x += Math.sin(unit.heading) * unit.speed * dt; unit.z += Math.cos(unit.heading) * unit.speed * dt; break; } case 'patrol': case 'engineer': { const segment = unit.assigned === null ? null : roads.segments[unit.assigned]; if (!segment) { unit.expires = 0; break; } if (unit.path.length > 0) { advance(unit, roads, dt); break; } // On station. Drive the road back and forth. unit.onStation += dt; const sweep = (Math.sin(unit.onStation * 0.28) + 1) / 2; const point = pointOnSegment(segment, sweep, 0); const dx = point.x - unit.x; const dz = point.z - unit.z; const gap = Math.hypot(dx, dz); if (gap > 1) { unit.heading = Math.atan2(dx, dz); const move = Math.min(gap, unit.speed * 0.45 * dt); unit.x += (dx / gap) * move; unit.z += (dz / gap) * move; } if (unit.role === 'patrol') { // Working the road is what brings it back down. The enemy is not // punishing you; they are re-securing a route and then leaving. step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt); // Presence settles the district a little too, but only a little. step.decayArea(unit.x, unit.z, PATROL_AREA_DECAY * dt); if (unit.onStation > PATROL_DURATION) unit.expires = 0; } else { const stage: BuildStage = unit.building ?? 'barricade'; const site = state.checkpoints.get(segment.id) ?? { segment: segment.id, stage, progress: 0, done: [], x: point.x, z: point.z, }; state.checkpoints.set(segment.id, site); if (site.done.includes(stage)) { // Already standing; nothing left for this one to do. unit.expires = 0; break; } site.stage = stage; site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS[stage]); if (site.progress >= 1) { site.done.push(stage); site.progress = 0; events.built.push({ segment: segment.id, stage }); // A tower is no use unless someone is standing on it. if (stage === 'tower') { const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5); makeUnit(state, { kind: 'soldier', faction: 'enemy', role: 'garrison', x: post.x, z: post.z, heading: post.yaw, speed: 0, hp: UNIT_HP.soldier * 2, path: [], expires: 1e9, assigned: segment.id, onStation: 0, cooldown: 0, // Up on the tower, shooting over its own barricade. elevation: 5, }); } unit.expires = 0; } } break; } case 'fighter': case 'militia': { // Close on the nearest enemy of the other army and stand your ground. const enemy = nearestHostile(state, unit, 140); if (enemy) { const gap = distance(unit, enemy); unit.heading = Math.atan2(enemy.x - unit.x, enemy.z - unit.z); if (gap > 45) { unit.x += Math.sin(unit.heading) * unit.speed * dt; unit.z += Math.cos(unit.heading) * unit.speed * dt; } break; } // Nothing to shoot at. Militia are holding ground rather than taking // it, so they drift about it instead of standing to attention — a // street of statues reads as scenery, which is the opposite of the // point of putting them there. if (unit.role !== 'militia') break; if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6; unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt; unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * dt; break; } case 'garrison': break; } } // --- Civilians get out of the way of a firefight --- for (const unit of state.units) { if (unit.faction !== 'civilian') continue; const danger = state.units.find( (other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE, ); if (!danger) continue; const away = Math.atan2(unit.x - danger.x, unit.z - danger.z); unit.x += Math.sin(away) * unit.speed * 1.4 * dt; unit.z += Math.cos(away) * unit.speed * 1.4 * dt; } // --- Checkpoints whose road has cooled off are abandoned --- for (const [segmentId, site] of state.checkpoints) { if (step.heatLevel(segmentId) === 'turret') continue; state.checkpoints.delete(segmentId); events.removed.push(segmentId); for (const unit of state.units) { if (unit.role === 'garrison' && unit.assigned === segmentId) unit.expires = 0; } void site; } // --- Ground your side no longer holds has none of your people standing on it --- // The line moves on its own, and when it moves over somebody they are not // there any more. It also keeps the population honest as the player advances: // militia are local, so they are culled at a far tighter radius than anything // with a job to do, and driving deep into occupied ground leaves them behind // rather than towing a friendly crowd along the front. for (const unit of state.units) { if (unit.role !== 'militia') continue; const control = controlAt(step.front, unit.x, unit.z); if (control !== 'liberated' && control !== 'contested') unit.expires = 0; if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0; } // --- Retire the dead, the finished and the far away --- const survivors: Unit[] = []; for (const unit of state.units) { // Anything with a job keeps it regardless of distance. Culling by range // caught units that were dispatched from further out than the cull radius // and deleted them on their first step — so the patrol or the engineer the // player had earned simply never turned up. Their `expires` bounds them. const tooFar = unit.assigned === null && distance(unit, player) > SIM_RADIUS * 1.3; if (unit.hp <= 0 || unit.expires <= 0 || tooFar) { if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) { state.dispatched.delete(unit.assigned); } continue; } survivors.push(unit); } state.units = survivors; return events; } /** Nearest unit of a faction this one would shoot at. */ export function nearestHostile(state: UnitState, unit: Unit, range: number): Unit | null { let best: Unit | null = null; let bestDistance = range; for (const other of state.units) { if (other.faction === unit.faction || other.faction === 'civilian') continue; if (unit.faction === 'civilian') continue; const d = distance(unit, other); if (d < bestDistance) { bestDistance = d; best = other; } } return best; } /** Which side holds the ground a unit is standing on. Used for spawn rules. */ export const controlOfUnit = (front: Front, unit: Unit): Control => controlAt(front, unit.x, unit.z); /** How far along its assigned road a patrol currently is, for tests. */ export const patrolProgress = (unit: Unit, segment: RoadSegment): number => projectOntoSegment(segment, unit.x, unit.z).t; export const roadSpeedOf = (segment: RoadSegment): number => ROAD_SPEED[segment.cls];