diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 29c3088..4a034c4 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -7,6 +7,7 @@ import { createHeat, createAreas } from './heat'; import { freshCondition } from './car'; import { AMBIENT_ALLIES, + AMBIENT_ALLY_CARS, AMBIENT_PATROLS, createUnits, dispatchTo, @@ -289,8 +290,9 @@ describe('a war going on regardless', () => { run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21)); for (const fighter of state.units.filter((u) => u.role === 'fighter')) { const control = controlAt(front, fighter.x, fighter.z); - // Spawned where the war is — they may drift, but not from behind a line. - expect(['contested', 'occupied']).toContain(control); + // Spawned where the war is, and far enough past your line that closing + // on each other cannot walk them back over it. + expect(control).not.toBe('liberated'); } }); }); @@ -749,24 +751,51 @@ describe('traffic that drives like traffic', () => { }; it('keeps right rather than straddling the centreline', () => { + // Sampled over four minutes rather than from one frozen frame: at any one + // instant only a handful of cars are squarely mid-segment, and four cars is + // not a measurement. + const state = createUnits(); let onTheRight = 0; let judged = 0; - for (const car of traffic()) { - // Only judge cars actually on a road, not ones crossing a junction. - const seg = world.roads.segments.find((s) => { - const t = - ((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length); - if (t < 0.15 || t > 0.85) return false; - const cx = s.ax + (s.bx - s.ax) * t; - const cz = s.az + (s.bz - s.az) * t; - return Math.hypot(car.x - cx, car.z - cz) < s.width / 2; - }); - if (!seg) continue; - judged++; - if (sideOfRoad(car, seg) > 0) onTheRight++; + for (let i = 0; i < 2400; i++) { + stepUnits( + state, + { + dt: 0.1, + now: i * 0.1, + player: { x: world.spawn.x, z: world.spawn.z }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + }, + world.roads, + graph, + makeRng(31), + ); + if (i % 10 !== 0) continue; + for (const car of state.units) { + if (car.role !== 'traffic' && car.role !== 'convoy') continue; + const seg = world.roads.segments.find((s) => { + const t = + ((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length); + // Well inside the segment: at a junction there is no side to be on. + if (t < 0.25 || t > 0.75) return false; + const cx = s.ax + (s.bx - s.ax) * t; + const cz = s.az + (s.bz - s.az) * t; + return Math.hypot(car.x - cx, car.z - cz) < s.width / 2; + }); + if (!seg) continue; + const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az); + // Only cars travelling along this road, not crossing it. + if (Math.abs(Math.cos(car.heading - along)) < 0.9) continue; + judged++; + if (sideOfRoad(car, seg) > 0) onTheRight++; + } } - expect(judged).toBeGreaterThan(3); - expect(onTheRight / judged).toBeGreaterThan(0.75); + expect(judged).toBeGreaterThan(200); + expect(onTheRight / judged).toBeGreaterThan(0.8); }); it('does not drive through the car in front', () => { @@ -819,3 +848,48 @@ describe('traffic that drives like traffic', () => { expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6); }); }); + +describe('your own side, with vehicles', () => { + const deepIn = (control: 'liberated' | 'contested' | 'occupied') => { + const wanted = { liberated: -140, contested: 40, occupied: 240 }[control]; + const depth = front.boundaries.liberated + wanted; + return { x: front.axis.x * depth, z: front.axis.z * depth }; + }; + + const convoys = (control: 'liberated' | 'contested' | 'occupied') => { + const state = createUnits(); + run(state, 200, { player: deepIn(control) }, makeRng(8)); + return state.units.filter((u) => u.role === 'convoy'); + }; + + it('puts allied vehicles on ground your side holds', () => { + expect(convoys('liberated').length).toBeGreaterThan(0); + expect(convoys('liberated').length).toBeLessThanOrEqual(AMBIENT_ALLY_CARS.liberated); + }); + + it('stops at the line, like everything else of yours', () => { + expect(convoys('occupied').length).toBe(0); + }); + + it('never routes one onto ground your side does not hold', () => { + const state = createUnits(); + run(state, 200, { player: deepIn('contested') }, makeRng(8)); + for (const u of state.units.filter((c) => c.role === 'convoy')) { + const control = controlAt(front, u.x, u.z); + expect(control === 'liberated' || control === 'contested').toBe(true); + } + }); + + it('carries a crew that the enemy will shoot at', () => { + // Insurgent faction, so nearestHostile treats them as a target and they + // treat the enemy as one — a convoy is not scenery. + const state = createUnits(); + run(state, 200, { player: deepIn('liberated') }, makeRng(8)); + for (const u of state.units.filter((c) => c.role === 'convoy')) { + expect(u.faction).toBe('insurgent'); + expect(u.kind).toBe('car'); + // Rounds leave the man riding in it, not the bonnet. + expect(u.elevation).toBeGreaterThan(1.2); + } + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index c4bbe18..86916cb 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -14,7 +14,7 @@ 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'; +import { controlAt, depthAt } from './regions'; export type Faction = 'enemy' | 'insurgent' | 'civilian'; export type UnitKind = 'car' | 'soldier'; @@ -33,7 +33,9 @@ export type Role = /** Fighting the other army. */ | 'fighter' /** Your own side, holding the ground it holds. Thick at home, thin at the line. */ - | 'militia'; + | 'militia' + /** Your own side, with a vehicle and somewhere to be. */ + | 'convoy'; export interface Unit { id: number; @@ -63,6 +65,8 @@ export interface Unit { hunting?: boolean; /** Current speed while chasing, wound up from a standing start. */ chaseSpeed?: number; + /** Junction most recently left, so the leg being driven is known. */ + lastNode?: number; } /** @@ -163,6 +167,8 @@ export const PATROL_AREA_DECAY = 0.0005; const BUILD_SECONDS: Record = { barricade: 22, tower: 40 }; /** Minimum gap between skirmishes breaking out. */ const SKIRMISH_SPACING = 55; +/** How far past your own line a fight has to start, so it stays past it. */ +const SKIRMISH_CLEARANCE = 70; /** * Standing patrols the enemy keeps on the roads near the player, by whose @@ -207,6 +213,22 @@ export const AMBIENT_ALLIES: Record = { frontier: 0, }; +/** + * Your own side's vehicles, on the same gradient as the men on foot. + * + * Fewer than the militia, because a truck is a scarcer thing than a man with a + * rifle, and because the roads have to stay legible: friendly traffic that + * outnumbered the civilian kind would turn every road into a convoy. They exist + * mostly so home ground has something moving on it that is *yours* — a road + * with only civilians on it reads the same behind the lines as past them. + */ +export const AMBIENT_ALLY_CARS: Record = { + liberated: 4, + contested: 2, + occupied: 0, + frontier: 0, +}; + /** * How far from the player a militiaman is still worth simulating. * @@ -375,46 +397,71 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): if (next === undefined) return true; const node = nodeById(roads, next); - const straightX = node.x - unit.x; - const straightZ = node.z - unit.z; - const remaining = Math.hypot(straightX, straightZ); + const remaining = Math.hypot(node.x - unit.x, node.z - unit.z); - // Judged on the junction itself, not on the offset aiming point, or a car - // would circle a node it had already reached. - if (remaining < 3) { + /* + * Drive a line parallel to the centreline, a share of the road's own + * half-width to the right of it — so a track gets a nudge and a trunk gets a + * lane, and two cars meeting head-on are on opposite sides of the road. + * + * Built from the leg actually being driven, which is why units remember the + * junction they last left. Deriving the axis from the car-to-node direction + * instead is nearly right in the middle of a leg and degenerates completely + * near either end of it, where the direction swings through ninety degrees + * over a few metres — and a grid map with hundred-metre blocks means cars + * spend most of their time near a junction. + */ + const previous = nodeById(roads, unit.lastNode ?? next); + let dirX = node.x - previous.x; + let dirZ = node.z - previous.z; + const legLength = Math.hypot(dirX, dirZ); + if (legLength < 1) { + // No leg to speak of — first step out of a spawn, or a doubled-back path. + dirX = (node.x - unit.x) / remaining; + dirZ = (node.z - unit.z) / remaining; + } else { + dirX /= legLength; + dirZ /= legLength; + } + // Right of the direction of travel. + const rightX = dirZ; + const rightZ = -dirX; + + const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2; + const offset = halfWidth * LANE_SHARE; + const laneX = previous.x + rightX * offset; + const laneZ = previous.z + rightZ * offset; + + /* + * Arrived, if either the junction is close enough or the car has simply + * driven past it. + * + * "Close enough" has to know about the lane, because the car is deliberately + * not aiming at the junction: on a fifteen-metre trunk it passes three and a + * half metres to one side of it. A fixed three-metre radius meant dispatched + * units drove straight past their destination and never registered arriving, + * so no patrol ever worked a road and no engineer ever poured any concrete. + */ + const beyond = (unit.x - node.x) * dirX + (unit.z - node.z) * dirZ; + if (remaining < 3 + offset || beyond > -0.5) { + unit.lastNode = next; unit.path.shift(); return unit.path.length === 0; } - // Drive a line parallel to the centreline, a share of the road's own - // half-width to the right of it — so a track gets a nudge and a trunk gets a - // lane. Offsetting the *destination* instead was the obvious thing and the - // wrong one: it only bends the path at the very end of the leg, leaving cars - // straddling the middle of every straight. - const ahead = unit.path.length > 1 ? unit.path[1]! : next; - const halfWidth = (segmentBetween(roads, next, ahead)?.width ?? 9) / 2; - const toward = Math.atan2(straightX, straightZ); - const forwardX = Math.sin(toward); - const forwardZ = Math.cos(toward); - const rightX = Math.cos(toward); - const rightZ = -Math.sin(toward); - - // Where the lane line sits, and how far off it this car currently is. - const toLaneX = node.x + rightX * halfWidth * LANE_SHARE - unit.x; - const toLaneZ = node.z + rightZ * halfWidth * LANE_SHARE - unit.z; - const lateral = toLaneX * rightX + toLaneZ * rightZ; - // Aim at a point on that line a little way ahead. Short lookahead converges - // hard and weaves; long lookahead never quite arrives. - const look = Math.min(LANE_LOOKAHEAD, Math.max(4, toLaneX * forwardX + toLaneZ * forwardZ)); - const aimX = unit.x + forwardX * look + rightX * lateral; - const aimZ = unit.z + forwardZ * look + rightZ * lateral; + // Pure pursuit: aim at the point on the lane line a fixed distance ahead of + // wherever the car currently projects onto it. Short lookahead weaves, long + // lookahead never quite arrives. + const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD; + const aimX = laneX + dirX * along; + const aimZ = laneZ + dirZ * along; const desired = Math.atan2(aimX - unit.x, aimZ - unit.z); unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt); // Close on the car in front and lift off. Only civilians defer; anyone with // somewhere to be leans on the horn and keeps going. - const gap = unit.role === 'traffic' ? carAhead(state, unit) : null; + const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null; const allowed = gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed); const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt); @@ -658,6 +705,56 @@ function spawnAlly( return false; } +/** + * One of your own side's vehicles, routing across ground your side holds. + * + * Built on the traffic spawner rather than the militia one: it has somewhere to + * be and drives the network to get there, which means it inherits lane + * discipline and following distance for nothing. + */ +function spawnAllyCar( + state: UnitState, + roads: RoadNetwork, + graph: Graph, + near: { x: number; z: number }, + front: Front, + rng: Rng, +): boolean { + const friendly = (n: { x: number; z: number }) => { + const control = controlAt(front, n.x, n.z); + return control === 'liberated' || control === 'contested'; + }; + const candidates = roads.nodes.filter((n) => { + const gap = distance(n, near); + return gap > 90 && gap < SIM_RADIUS && friendly(n); + }); + if (candidates.length < 2) return false; + + const from = candidates[Math.floor(rng() * candidates.length)]!; + const to = candidates[Math.floor(rng() * candidates.length)]!; + if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return false; + const path = routeTo(graph, roads, from.id, to.id); + if (path.length === 0) return false; + + makeUnit(state, { + kind: 'car', + faction: 'insurgent', + role: 'convoy', + x: from.x, + z: from.z, + heading: 0, + speed: SPEED.car * (0.75 + rng() * 0.3), + hp: UNIT_HP.car, + path, + expires: 500, + assigned: null, + onStation: 0, + cooldown: 1, + elevation: CREW_ELEVATION, + }); + return true; +} + /** Two squads run into each other. The player is not invited. */ function spawnSkirmish( state: UnitState, @@ -672,7 +769,11 @@ function spawnSkirmish( 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 (control !== 'contested' && control !== 'occupied') return false; + // And far enough past the line that closing on each other cannot walk them + // over it. Fighters advance up to their standoff range, so a skirmish + // spawned right on the boundary ends up being fought behind your own. + return depthAt(front, n.x, n.z) > front.boundaries.liberated + SKIRMISH_CLEARANCE; }); if (candidates.length === 0) return false; @@ -783,6 +884,10 @@ export function stepUnits( if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) { spawnAlly(state, player, step.front, rng, step.blocked); } + const allyCars = state.units.filter((u) => u.role === 'convoy').length; + if (allyCars < AMBIENT_ALLY_CARS[here] && rng() < dt * 2) { + spawnAllyCar(state, roads, graph, player, step.front, rng); + } // --- A war going on regardless of the player --- if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) { @@ -825,10 +930,22 @@ export function stepUnits( unit.chaseSpeed = undefined; switch (unit.role) { - case 'traffic': { + case 'traffic': + case 'convoy': { if (advance(unit, roads, state, dt)) { - const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!; - unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id); + // Somewhere else to be. A convoy stays on ground its own side holds; + // a taxi does not care. + const pool = + unit.role === 'convoy' + ? roads.nodes.filter((n) => { + const control = controlAt(step.front, n.x, n.z); + return control === 'liberated' || control === 'contested'; + }) + : roads.nodes; + const to = pool[Math.floor(rng() * pool.length)]; + unit.path = to + ? routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id) + : []; if (unit.path.length === 0) unit.expires = 0; } break; @@ -968,6 +1085,11 @@ export function stepUnits( for (let j = i + 1; j < state.units.length; j++) { const b = state.units[j]!; if (b.kind !== 'car') continue; + // Only cars going roughly the same way. Two passing in opposite + // directions are already a lane apart, which on a narrow road is less + // than this radius — separating them too shoved both off their own side + // and undid the lane discipline entirely. + if (Math.cos(b.heading - a.heading) < 0) continue; const dx = b.x - a.x; const dz = b.z - a.z; const gap = Math.hypot(dx, dz); @@ -1009,10 +1131,13 @@ export function stepUnits( // 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; + if (unit.role !== 'militia' && unit.role !== 'convoy') 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; + // Vehicles get more rope than men on foot: they cover ground, and culling + // one at walking-pace range would delete it halfway down its own route. + const reach = unit.role === 'convoy' ? SIM_RADIUS : MILITIA_RADIUS; + if (distance(unit, player) > reach) unit.expires = 0; } // --- Retire the dead, the finished and the far away ---