Patrols everywhere they should be, and concrete that has to be poured

Three fixes, all from watching it run.

Cars stuck against buildings. routeTo dropped the node a route starts from,
which is fine for a unit standing on it and wrong for anything re-routing from
mid-segment: its first leg became a straight line to a node two hops away,
across buildings and open country. Routes now include their start node so a unit
rejoins the road before setting off.

No patrols anywhere. They only existed as a reaction to the player's own tracks,
so hostile territory was empty until you had personally made a road notorious.
Enemy ground now carries standing patrols by how dangerous it is — none behind
your lines, five on the frontier — spawned onto their roads so the territory has
them the moment you arrive.

This surfaced a second bug: dispatched units were culled at 546m from the player
while dispatch origins reached 600m, so a patrol or engineer could be deleted on
its first step and simply never turn up. Anything with an assignment is now kept
regardless of range; its expiry still bounds it.

Barricades appearing instantly. Crossing a heat threshold placed concrete on the
road with nobody involved. Construction is now staged: an engineer is dispatched
for the barricade, another for the tower, and heatProps is driven by finished
builds rather than by heat levels. It still refuses to close a slab around a car
parked in the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 21:14:19 +02:00
parent 4feae2c731
commit f4ed3cdc48
7 changed files with 391 additions and 142 deletions

View File

@ -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.

View File

@ -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 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 };
async function setup() {
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();
const 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);
}
return {
physics,
scene,
props,
baseBodies: physics.rapier.bodies.len(),
baseMeshes: scene.children.length,
};
}
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);
});
});

View File

@ -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<HeatProp['kind'], THREE.MeshStandardMaterial> = {
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<number, Placed>();
/** Level changes waiting for the player to get off the road in question. */
const pending = new Set<number>();
/** Builds finished while the player was stood in the way, waiting for room. */
const waiting = new Set<number>();
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),
};
}

View File

@ -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);

View File

@ -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));

View File

@ -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<Parameters<typeof stepUnits>[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();

View File

@ -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<number, Checkpoint>;
@ -98,11 +115,30 @@ const SPEED: Record<UnitKind, number> = { 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<BuildStage, number> = { 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<Control, number> = {
liberated: 0,
contested: 1,
occupied: 3,
frontier: 5,
};
export const UNIT_HP: Record<UnitKind, number> = { 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.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.built = true;
events.built.push(segment.id);
// Someone has to man it once it stands.
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);