diff --git a/README.md b/README.md index 8e700b7..3be302a 100644 --- a/README.md +++ b/README.md @@ -190,17 +190,24 @@ job is what keeps the car alive a while longer. Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited. -**Nothing appears any more — things are sent.** When a road turns notorious a -patrol is *dispatched*: it starts at a junction a few hundred metres away, drives -to the road, sweeps up and down it for about a minute, and while it is working -the road it brings the heat back down. Then it leaves. The enemy is not punishing +**Enemy ground is patrolled because it is enemy ground.** There are standing +patrols on the roads wherever you are past the line, and more of them the deeper +in you go — none in liberated territory, one in contested, three in occupied, +five on the frontier. Nobody summoned these; they are what hostile territory +looks like. + +**On top of that, nothing appears — things are sent.** When a road turns +notorious a patrol is *dispatched*: it starts at a junction a few hundred metres +away, drives there, sweeps up and down for about a minute, and while it works the +road it brings the heat back down. Then it leaves. The enemy is not punishing you; they are re-securing a route and going home. -**Checkpoints are built.** At `turret` an engineer is dispatched, and the tower -rises over about forty seconds of someone standing on site. Arrive early and you -find a half-built stump and no one on it. Once it stands, a gunner mans it, up on -top so he shoots over his own barricade. Let the road cool and the whole thing is -abandoned. +**Everything on a road is built, including the concrete.** At `barricade` an +engineer is dispatched and the blocks go up over about twenty seconds of someone +standing there; at `turret` another is sent and the tower takes forty. Arrive +mid-build and you find a half-finished stump with nobody on it. Only once the +tower stands does a gunner man it, up on top so he shoots over his own barricade. +Let the road cool and the whole thing is abandoned. **Traffic and people.** Civilian cars route across the network with somewhere to be; people wander on foot near the buildings. Both scatter when shooting starts. diff --git a/src/heatProps.test.ts b/src/heatProps.test.ts index fb859a5..39c6d27 100644 --- a/src/heatProps.test.ts +++ b/src/heatProps.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import * as THREE from 'three'; import { createHeatProps } from './heatProps'; import { createPhysics } from './physics/physics'; -import { createHeat, stepHeat } from './sim/heat'; +import { propsFor } from './sim/heat'; import { generateWorld } from './sim/world'; /** @@ -10,62 +10,86 @@ import { generateWorld } from './sim/world'; * so the prop lifecycle can be checked end to end — including that removing a * barricade removes its collider, not just its mesh. */ -describe('heat props lifecycle', () => { - it('builds and tears down colliders and meshes together', async () => { - const world = generateWorld(3, 0); - const physics = await createPhysics(world); - const scene = new THREE.Scene(); - const props = createHeatProps(world.roads, physics, scene); - const heat = createHeat(world.roads); +const world = generateWorld(3, 0); +const segment = world.roads.segments[0]!; +/** Somewhere the player is nowhere near the road in question. */ +const AWAY = { x: 99999, z: 99999 }; - const baseBodies = physics.rapier.bodies.len(); - const baseMeshes = scene.children.length; +async function setup() { + const physics = await createPhysics(world); + const scene = new THREE.Scene(); + const props = createHeatProps(world.roads, physics, scene); + return { + physics, + scene, + props, + baseBodies: physics.rapier.bodies.len(), + baseMeshes: scene.children.length, + }; +} - const drive = (metres: number) => { - for (let m = 0; m < metres; m++) { - props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0); - } - }; - const idle = (seconds: number) => { - for (let i = 0; i < seconds * 60; i++) { - props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null); - } - }; - - drive(400); - expect(heat.level[0]).toBe('turret'); - // Still on the road, so nothing has been built around the car yet. +describe('barricades appear only once someone has built them', () => { + it('places nothing until a build is reported finished', async () => { + const { physics, scene, props, baseBodies, baseMeshes } = await setup(); + // Heat alone does nothing now. An engineer has to have done the work. + for (let i = 0; i < 100; i++) props.sync(AWAY); expect(physics.rapier.bodies.len()).toBe(baseBodies); + expect(scene.children.length).toBe(baseMeshes); - // Leaving it lets the deferred work land. - idle(1); - const hotBodies = physics.rapier.bodies.len(); - expect(hotBodies).toBeGreaterThan(baseBodies); - expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies); + props.finished(segment.id); + props.sync(AWAY); + const expected = propsFor(segment, 'barricade').length; + expect(expected).toBeGreaterThan(0); + expect(physics.rapier.bodies.len() - baseBodies).toBe(expected); + expect(scene.children.length - baseMeshes).toBe(expected); + }); - // Left alone, the road should give everything back. - idle(300); - expect(heat.level[0]).toBe('clear'); + it('takes colliders and meshes away together when abandoned', async () => { + const { physics, scene, props, baseBodies, baseMeshes } = await setup(); + props.finished(segment.id); + props.sync(AWAY); + expect(props.has(segment.id)).toBe(true); + + props.abandon(segment.id); + expect(props.has(segment.id)).toBe(false); expect(physics.rapier.bodies.len()).toBe(baseBodies); expect(scene.children.length).toBe(baseMeshes); }); - it('does not leak bodies when a road escalates through every level', async () => { - const world = generateWorld(3, 0); - const physics = await createPhysics(world); - const scene = new THREE.Scene(); - const props = createHeatProps(world.roads, physics, scene); - const heat = createHeat(world.roads); - const baseBodies = physics.rapier.bodies.len(); + it('waits rather than closing a slab around the car standing in the gap', async () => { + const { physics, props, baseBodies } = await setup(); + const block = propsFor(segment, 'barricade')[0]!; - for (let cycle = 0; cycle < 3; cycle++) { - for (let m = 0; m < 400; m++) { - props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0); - } - for (let i = 0; i < 300 * 60; i++) { - props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null); - } + props.finished(segment.id); + // Parked right where the concrete is going. + props.sync({ x: block.x, z: block.z }); + expect(physics.rapier.bodies.len()).toBe(baseBodies); + + // Moved off; the build lands. + props.sync(AWAY); + expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies); + }); + + it('does not leak bodies over repeated build and abandon cycles', async () => { + const { physics, scene, props, baseBodies, baseMeshes } = await setup(); + for (let cycle = 0; cycle < 4; cycle++) { + props.finished(segment.id); + props.sync(AWAY); + expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies); + props.abandon(segment.id); expect(physics.rapier.bodies.len()).toBe(baseBodies); + expect(scene.children.length).toBe(baseMeshes); } }); + + it('is idempotent, so a repeated report does not double up', async () => { + const { physics, props, baseBodies } = await setup(); + props.finished(segment.id); + props.sync(AWAY); + const after = physics.rapier.bodies.len(); + props.finished(segment.id); + props.sync(AWAY); + expect(physics.rapier.bodies.len()).toBe(after); + expect(after).toBeGreaterThan(baseBodies); + }); }); diff --git a/src/heatProps.ts b/src/heatProps.ts index 60c0f28..4bec13e 100644 --- a/src/heatProps.ts +++ b/src/heatProps.ts @@ -1,13 +1,17 @@ /** - * Turns heat levels into things that physically exist on the road. + * Puts the concrete on a road once someone has actually built it. * - * This is an integration layer: it is allowed to touch both Rapier and three.js, - * which is why it sits outside src/sim/. The sim decides *what* should be there - * ({@link propsFor}); this only builds and tears it down. + * This used to be driven straight off the heat level: cross a threshold and a + * barricade existed. It does not any more. An engineer is dispatched, drives + * there, and stands on site while it goes up; only then does this get told to + * place anything. What arrives here is a finished build, not a state change. + * + * Integration layer: it is allowed to touch both Rapier and three.js, because it + * owns objects that must exist in both or in neither. */ import * as THREE from 'three'; import type RAPIER from '@dimforge/rapier3d-compat'; -import { propsFor, type HeatProp, type HeatState } from './sim/heat'; +import { propsFor, type HeatProp } from './sim/heat'; import type { RoadNetwork } from './sim/roads'; import type { PhysicsWorld } from './physics/physics'; @@ -17,19 +21,18 @@ const MATERIALS: Record = { const BOX = new THREE.BoxGeometry(1, 1, 1); +/** Never close a barricade around the car that is standing in the gap. */ +const PLAYER_CLEARANCE = 8; + interface Placed { bodies: RAPIER.RigidBody[]; meshes: THREE.Mesh[]; } -export function createHeatProps( - roads: RoadNetwork, - physics: PhysicsWorld, - scene: THREE.Scene, -) { +export function createHeatProps(roads: RoadNetwork, physics: PhysicsWorld, scene: THREE.Scene) { const placed = new Map(); - /** Level changes waiting for the player to get off the road in question. */ - const pending = new Set(); + /** Builds finished while the player was stood in the way, waiting for room. */ + const waiting = new Set(); const clear = (segmentId: number) => { const existing = placed.get(segmentId); @@ -39,9 +42,12 @@ export function createHeatProps( placed.delete(segmentId); }; - const build = (segmentId: number, heat: HeatState) => { + const raise = (segmentId: number): void => { + if (placed.has(segmentId)) return; const segment = roads.segments[segmentId]!; - const props = propsFor(segment, heat.level[segmentId]!); + // 'barricade' is the level whose props are the concrete itself; the tower + // is a unit-owned structure and does not come from here. + const props = propsFor(segment, 'barricade'); if (props.length === 0) return; const entry: Placed = { bodies: [], meshes: [] }; @@ -60,26 +66,39 @@ export function createHeatProps( placed.set(segmentId, entry); }; - return { - /** - * Rebuild the segments whose level changed — but never the one the car is - * currently on. A barricade appearing around the player would spawn a static - * collider inside the chassis, and watching a checkpoint assemble itself in - * the mirror would break the fiction anyway. Changes wait until they are - * out of sight, which is how the player is meant to find them: by returning. - * - * Call every step, not only when something changed, so deferred work drains. - */ - sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) { - for (const id of changedSegmentIds) pending.add(id); - if (pending.size === 0) return; + const tooClose = (segmentId: number, player: { x: number; z: number }): boolean => { + const segment = roads.segments[segmentId]!; + return propsFor(segment, 'barricade').some( + (prop) => Math.hypot(prop.x - player.x, prop.z - player.z) < PLAYER_CLEARANCE, + ); + }; - for (const id of [...pending]) { - if (id === occupiedSegmentId) continue; - pending.delete(id); - clear(id); - build(id, heat); + return { + /** A barricade has been finished on this road. */ + finished(segmentId: number) { + waiting.add(segmentId); + }, + + /** The road cooled off, or the checkpoint was abandoned. */ + abandon(segmentId: number) { + waiting.delete(segmentId); + clear(segmentId); + }, + + /** + * Call every step. Places anything finished as soon as there is room — a + * block spawning on top of the car would put a static collider inside the + * chassis, so a player parked in the gap makes the last slab wait. + */ + sync(player: { x: number; z: number }) { + if (waiting.size === 0) return; + for (const id of [...waiting]) { + if (tooClose(id, player)) continue; + waiting.delete(id); + raise(id); } }, + + has: (segmentId: number) => placed.has(segmentId), }; } diff --git a/src/main.ts b/src/main.ts index 236ce46..9046872 100644 --- a/src/main.ts +++ b/src/main.ts @@ -217,15 +217,16 @@ async function boot() { { x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] }, true, ); - // Rebuild whatever the restored heat implies, so checkpoints are standing - // where the save says they are rather than appearing as you drive past. stepFront(front, 0); refreshSegmentControl(); - heatProps.sync( - model.roads.segments.map((s) => s.id), - heat, - null, - ); + // A save does not record who was standing where, so barricades on roads + // that were already hot are treated as already built rather than making the + // player wait for a fresh engineer to be sent out. + for (const segment of model.roads.segments) { + if (heat.level[segment.id] === 'barricade' || heat.level[segment.id] === 'turret') { + heatProps.finished(segment.id); + } + } } // Chassis transform at the end of the last two fixed steps, for interpolation. @@ -296,15 +297,25 @@ async function boot() { // the ground the car happens to be standing on. decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!], }); - heatProps.sync(heatChanged, heat, currentSegment); + heatProps.sync({ x: at.x, z: at.z }); - // Escalation is now something the enemy has to *do*. A road that turns - // notorious gets a patrol sent to it; one that turns worse gets an - // engineer sent to build a checkpoint. Both have to drive here first. + // Escalation is entirely something the enemy has to *do*. Every level + // sends somebody: a patrol to work the road, an engineer to pour the + // concrete, another to put the tower up. Nothing appears on its own. for (const id of heatChanged) { + const segment = model.roads.segments[id]!; const level = heat.level[id]!; - if (level === 'patrol') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'patrol', chatterRng); - if (level === 'turret') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'engineer', chatterRng); + if (level === 'patrol') { + dispatchTo(units, model.roads, graph, segment, 'patrol', chatterRng); + } + if (level === 'barricade') { + dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'barricade'); + } + if (level === 'turret') { + dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'tower'); + } + // Cooled back down: whatever is standing there stops being maintained. + if (level === 'clear' || level === 'patrol') heatProps.abandon(id); } // Driving a road is how you learn what is on it, and seeing ground is how // it stops being a blank on the map. @@ -442,17 +453,23 @@ async function boot() { chatterRng, ); - // Finished towers become solid; abandoned ones stop being solid. - for (const id of unitEvents.built) { - const site = units.checkpoints.get(id); - if (!site || towerBodies.has(id)) continue; + // Whatever an engineer just finished now exists in the world. + for (const { segment, stage } of unitEvents.built) { + if (stage === 'barricade') { + heatProps.finished(segment); + say('They have put concrete across that road.', 6); + continue; + } + const site = units.checkpoints.get(segment); + if (!site || towerBodies.has(segment)) continue; towerBodies.set( - id, + segment, physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }), ); say('They have finished the tower on that road.', 6); } for (const id of unitEvents.removed) { + heatProps.abandon(id); const body = towerBodies.get(id); if (!body) continue; physics.removeBody(body); diff --git a/src/render/units.ts b/src/render/units.ts index 96139be..d1b2520 100644 --- a/src/render/units.ts +++ b/src/render/units.ts @@ -122,7 +122,10 @@ export function createUnitView(scene: THREE.Scene) { let towerCount = 0; for (const site of units.checkpoints.values()) { if (towerCount >= towers.count) break; - const built = site.built; + // A tower only stands once its own stage is done. While the barricade + // is going up there is nothing here but activity. + const built = site.done.includes('tower'); + if (!built && site.stage !== 'tower') continue; const height = built ? 1 : Math.max(0.15, site.progress); position.set(site.x, (6 * height) / 2, site.z); scratch.compose(position, quaternion.identity(), scale.set(1, height, 1)); diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 91506b5..f9a045f 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -6,8 +6,10 @@ import { createFront, controlAt } from './regions'; import { createHeat } from './heat'; import { freshCondition } from './car'; import { + AMBIENT_PATROLS, createUnits, dispatchTo, + hasBuilt, stepUnits, PATROL_HEAT_DECAY, patrolProgress, @@ -25,7 +27,11 @@ function run( overrides: Partial[1]> = {}, rng = makeRng(3), ) { - const events = { built: [] as number[], removed: [] as number[], skirmish: false }; + const events = { + built: [] as Array<{ segment: number; stage: string }>, + removed: [] as number[], + skirmish: false, + }; for (let i = 0; i < seconds * 10; i++) { const step = stepUnits( state, @@ -137,25 +143,47 @@ describe('patrols are dispatched, not conjured', () => { }); }); -describe('checkpoints have to be built', () => { +describe('everything on a road has to be built', () => { const segment = world.roads.segments[6]!; + const atSite = { player: { x: segment.ax, z: segment.az } }; - it('does not exist until an engineer has stood there long enough', () => { + it('pours no concrete until an engineer has stood there long enough', () => { const state = createUnits(); - dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); - // Nothing is standing yet. + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade'); + // Dispatched, but nothing is standing on the road yet. expect(state.checkpoints.size).toBe(0); - const events = run(state, 200, { player: { x: segment.ax, z: segment.az } }); - expect(events.built).toContain(segment.id); - const site = state.checkpoints.get(segment.id)!; - expect(site.built).toBe(true); + const events = run(state, 200, atSite); + expect(events.built).toContainEqual({ segment: segment.id, stage: 'barricade' }); + expect(hasBuilt(state.checkpoints.get(segment.id), 'barricade')).toBe(true); }); - it('is manned once it stands', () => { + it('does not raise a tower for a barricade job', () => { const state = createUnits(); - dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); - run(state, 200, { player: { x: segment.ax, z: segment.az } }); + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade'); + run(state, 200, atSite); + expect(hasBuilt(state.checkpoints.get(segment.id), 'tower')).toBe(false); + // And nobody is manning a checkpoint that is only concrete. + expect(state.units.some((u) => u.role === 'garrison')).toBe(false); + }); + + it('takes a second engineer, and longer, to put the tower up', () => { + const state = createUnits(); + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade'); + run(state, 200, atSite); + state.dispatched.delete(segment.id); + + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(4), 'tower'); + const events = run(state, 260, atSite); + expect(events.built).toContainEqual({ segment: segment.id, stage: 'tower' }); + const site = state.checkpoints.get(segment.id)!; + expect(site.done).toEqual(['barricade', 'tower']); + }); + + it('mans the tower once it stands', () => { + const state = createUnits(); + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower'); + run(state, 260, atSite); const gunner = state.units.find((u) => u.role === 'garrison'); expect(gunner).toBeDefined(); // Up on the tower, so it shoots over its own barricade. @@ -164,20 +192,53 @@ describe('checkpoints have to be built', () => { it('is abandoned when the road stops being worth guarding', () => { const state = createUnits(); - dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); - run(state, 200, { player: { x: segment.ax, z: segment.az } }); + dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower'); + run(state, 260, atSite); expect(state.checkpoints.size).toBe(1); - const events = run(state, 5, { - player: { x: segment.ax, z: segment.az }, - heatLevel: () => 'clear', - }); + const events = run(state, 5, { ...atSite, heatLevel: () => 'clear' }); expect(events.removed).toContain(segment.id); expect(state.checkpoints.size).toBe(0); expect(state.units.some((u) => u.role === 'garrison')).toBe(false); }); }); +describe('enemy ground is patrolled because it is enemy ground', () => { + /** Somewhere well past the line, so the player is in hostile territory. */ + const deep = (() => { + const depth = front.boundaries.occupied + 60; + return { x: front.axis.x * depth, z: front.axis.z * depth }; + })(); + + it('keeps no patrols behind your own lines', () => { + const state = createUnits(); + run(state, 300, { player: { x: world.spawn.x, z: world.spawn.z } }); + expect(state.units.some((u) => u.role === 'patrol')).toBe(false); + }); + + it('has patrols already working the roads out past the front', () => { + const state = createUnits(); + run(state, 200, { player: deep }); + const patrols = state.units.filter((u) => u.role === 'patrol'); + // Nobody triggered these: the player never drove anything here. + expect(patrols.length).toBeGreaterThan(0); + for (const patrol of patrols) expect(patrol.assigned).not.toBeNull(); + }); + + it('puts more of them on more dangerous ground', () => { + expect(AMBIENT_PATROLS.liberated).toBe(0); + expect(AMBIENT_PATROLS.contested).toBeLessThan(AMBIENT_PATROLS.occupied); + expect(AMBIENT_PATROLS.occupied).toBeLessThan(AMBIENT_PATROLS.frontier); + }); + + it('never stacks two patrols onto the same road', () => { + const state = createUnits(); + run(state, 400, { player: deep }); + const roads = state.units.filter((u) => u.role === 'patrol').map((u) => u.assigned); + expect(new Set(roads).size).toBe(roads.length); + }); +}); + describe('a war going on regardless', () => { it('breaks out fights between the two armies', () => { const state = createUnits(); diff --git a/src/sim/units.ts b/src/sim/units.ts index 1c6f908..c135655 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -55,17 +55,34 @@ export interface Unit { 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; } +/** + * 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; - /** 0..1. A tower does not exist until someone builds it. */ + /** What is going up right now. */ + stage: BuildStage; + /** 0..1 toward the current stage. */ progress: number; - built: boolean; + /** 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; @@ -98,11 +115,30 @@ const SPEED: Record = { car: 14, soldier: 2.4 }; const PATROL_DURATION = 90; /** Heat removed per second by a patrol actually driving its assigned road. */ export const PATROL_HEAT_DECAY = 0.012; -/** Seconds of an engineer standing on site to finish a checkpoint. */ -const BUILD_SECONDS = 40; +/** + * 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, +}; + export const UNIT_HP: Record = { car: 60, soldier: 30 }; // --- Helpers -------------------------------------------------------------- @@ -154,8 +190,18 @@ function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean { 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.slice(1) ?? []; + return findRoute(graph, from, to, travelTime(roads))?.nodes ?? []; } // --- Spawning ------------------------------------------------------------- @@ -234,6 +280,7 @@ export function dispatchTo( segment: RoadSegment, role: 'patrol' | 'engineer', rng: Rng, + stage: BuildStage = 'barricade', ): Unit | null { if (state.dispatched.has(segment.id)) return null; @@ -241,7 +288,8 @@ export function dispatchTo( // 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); - return d > 150 && d < 600; + // 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)]!; @@ -265,9 +313,57 @@ export function dispatchTo( 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; +} + /** Two squads run into each other. The player is not invited. */ function spawnSkirmish( state: UnitState, @@ -330,9 +426,9 @@ export interface UnitStep { } export interface UnitEvents { - /** Checkpoints finished this step, so the renderer can build them. */ - built: number[]; - /** Checkpoints abandoned, so the renderer can take them away. */ + /** 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; } @@ -357,6 +453,12 @@ export function stepUnits( spawnPedestrian(state, player, rng); } + // --- Enemy ground is patrolled because it is enemy ground --- + const patrols = state.units.filter((u) => u.role === 'patrol').length; + if (patrols < AMBIENT_PATROLS[controlAt(step.front, player.x, player.z)] && rng() < dt * 1.5) { + spawnAmbientPatrol(state, roads, 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)) { @@ -419,20 +521,32 @@ export function stepUnits( step.decayHeat(segment.id, PATROL_HEAT_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, - built: false, + done: [], x: point.x, z: point.z, }; state.checkpoints.set(segment.id, site); - if (!site.built) { - site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS); - if (site.progress >= 1) { - site.built = true; - events.built.push(segment.id); - // Someone has to man it once it stands. + + 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', @@ -451,8 +565,8 @@ export function stepUnits( // Up on the tower, shooting over its own barricade. elevation: 5, }); - unit.expires = 0; } + unit.expires = 0; } } break; @@ -503,7 +617,11 @@ export function stepUnits( // --- Retire the dead, the finished and the far away --- const survivors: Unit[] = []; for (const unit of state.units) { - const tooFar = distance(unit, player) > SIM_RADIUS * 1.3; + // 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);