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 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. 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 **Enemy ground is patrolled because it is enemy ground.** There are standing
patrol is *dispatched*: it starts at a junction a few hundred metres away, drives patrols on the roads wherever you are past the line, and more of them the deeper
to the road, sweeps up and down it for about a minute, and while it is working in you go — none in liberated territory, one in contested, three in occupied,
the road it brings the heat back down. Then it leaves. The enemy is not punishing 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. you; they are re-securing a route and going home.
**Checkpoints are built.** At `turret` an engineer is dispatched, and the tower **Everything on a road is built, including the concrete.** At `barricade` an
rises over about forty seconds of someone standing on site. Arrive early and you engineer is dispatched and the blocks go up over about twenty seconds of someone
find a half-built stump and no one on it. Once it stands, a gunner mans it, up on standing there; at `turret` another is sent and the tower takes forty. Arrive
top so he shoots over his own barricade. Let the road cool and the whole thing is mid-build and you find a half-finished stump with nobody on it. Only once the
abandoned. 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 **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. 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 * as THREE from 'three';
import { createHeatProps } from './heatProps'; import { createHeatProps } from './heatProps';
import { createPhysics } from './physics/physics'; import { createPhysics } from './physics/physics';
import { createHeat, stepHeat } from './sim/heat'; import { propsFor } from './sim/heat';
import { generateWorld } from './sim/world'; 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 * so the prop lifecycle can be checked end to end including that removing a
* barricade removes its collider, not just its mesh. * barricade removes its collider, not just its mesh.
*/ */
describe('heat props lifecycle', () => { const world = generateWorld(3, 0);
it('builds and tears down colliders and meshes together', async () => { const segment = world.roads.segments[0]!;
const world = generateWorld(3, 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 physics = await createPhysics(world);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const props = createHeatProps(world.roads, physics, scene); const props = createHeatProps(world.roads, physics, scene);
const heat = createHeat(world.roads); return {
physics,
const baseBodies = physics.rapier.bodies.len(); scene,
const baseMeshes = scene.children.length; props,
baseBodies: physics.rapier.bodies.len(),
const drive = (metres: number) => { baseMeshes: scene.children.length,
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); describe('barricades appear only once someone has built them', () => {
expect(heat.level[0]).toBe('turret'); it('places nothing until a build is reported finished', async () => {
// Still on the road, so nothing has been built around the car yet. 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(physics.rapier.bodies.len()).toBe(baseBodies);
expect(scene.children.length).toBe(baseMeshes);
// Leaving it lets the deferred work land. props.finished(segment.id);
idle(1); props.sync(AWAY);
const hotBodies = physics.rapier.bodies.len(); const expected = propsFor(segment, 'barricade').length;
expect(hotBodies).toBeGreaterThan(baseBodies); expect(expected).toBeGreaterThan(0);
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies); expect(physics.rapier.bodies.len() - baseBodies).toBe(expected);
expect(scene.children.length - baseMeshes).toBe(expected);
});
// Left alone, the road should give everything back. it('takes colliders and meshes away together when abandoned', async () => {
idle(300); const { physics, scene, props, baseBodies, baseMeshes } = await setup();
expect(heat.level[0]).toBe('clear'); 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(physics.rapier.bodies.len()).toBe(baseBodies);
expect(scene.children.length).toBe(baseMeshes); expect(scene.children.length).toBe(baseMeshes);
}); });
it('does not leak bodies when a road escalates through every level', async () => { it('waits rather than closing a slab around the car standing in the gap', async () => {
const world = generateWorld(3, 0); const { physics, props, baseBodies } = await setup();
const physics = await createPhysics(world); const block = propsFor(segment, 'barricade')[0]!;
const scene = new THREE.Scene();
const props = createHeatProps(world.roads, physics, scene);
const heat = createHeat(world.roads);
const baseBodies = physics.rapier.bodies.len();
for (let cycle = 0; cycle < 3; cycle++) { props.finished(segment.id);
for (let m = 0; m < 400; m++) { // Parked right where the concrete is going.
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0); props.sync({ x: block.x, z: block.z });
}
for (let i = 0; i < 300 * 60; i++) {
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
}
expect(physics.rapier.bodies.len()).toBe(baseBodies); 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, * This used to be driven straight off the heat level: cross a threshold and a
* which is why it sits outside src/sim/. The sim decides *what* should be there * barricade existed. It does not any more. An engineer is dispatched, drives
* ({@link propsFor}); this only builds and tears it down. * 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 * as THREE from 'three';
import type RAPIER from '@dimforge/rapier3d-compat'; 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 { RoadNetwork } from './sim/roads';
import type { PhysicsWorld } from './physics/physics'; 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); 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 { interface Placed {
bodies: RAPIER.RigidBody[]; bodies: RAPIER.RigidBody[];
meshes: THREE.Mesh[]; meshes: THREE.Mesh[];
} }
export function createHeatProps( export function createHeatProps(roads: RoadNetwork, physics: PhysicsWorld, scene: THREE.Scene) {
roads: RoadNetwork,
physics: PhysicsWorld,
scene: THREE.Scene,
) {
const placed = new Map<number, Placed>(); const placed = new Map<number, Placed>();
/** Level changes waiting for the player to get off the road in question. */ /** Builds finished while the player was stood in the way, waiting for room. */
const pending = new Set<number>(); const waiting = new Set<number>();
const clear = (segmentId: number) => { const clear = (segmentId: number) => {
const existing = placed.get(segmentId); const existing = placed.get(segmentId);
@ -39,9 +42,12 @@ export function createHeatProps(
placed.delete(segmentId); 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 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; if (props.length === 0) return;
const entry: Placed = { bodies: [], meshes: [] }; const entry: Placed = { bodies: [], meshes: [] };
@ -60,26 +66,39 @@ export function createHeatProps(
placed.set(segmentId, entry); placed.set(segmentId, entry);
}; };
return { const tooClose = (segmentId: number, player: { x: number; z: number }): boolean => {
/** const segment = roads.segments[segmentId]!;
* Rebuild the segments whose level changed but never the one the car is return propsFor(segment, 'barricade').some(
* currently on. A barricade appearing around the player would spawn a static (prop) => Math.hypot(prop.x - player.x, prop.z - player.z) < PLAYER_CLEARANCE,
* 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;
for (const id of [...pending]) { return {
if (id === occupiedSegmentId) continue; /** A barricade has been finished on this road. */
pending.delete(id); finished(segmentId: number) {
clear(id); waiting.add(segmentId);
build(id, heat); },
/** 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] }, { x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
true, 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); stepFront(front, 0);
refreshSegmentControl(); refreshSegmentControl();
heatProps.sync( // A save does not record who was standing where, so barricades on roads
model.roads.segments.map((s) => s.id), // that were already hot are treated as already built rather than making the
heat, // player wait for a fresh engineer to be sent out.
null, 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. // 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. // the ground the car happens to be standing on.
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!], 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 // Escalation is entirely something the enemy has to *do*. Every level
// notorious gets a patrol sent to it; one that turns worse gets an // sends somebody: a patrol to work the road, an engineer to pour the
// engineer sent to build a checkpoint. Both have to drive here first. // concrete, another to put the tower up. Nothing appears on its own.
for (const id of heatChanged) { for (const id of heatChanged) {
const segment = model.roads.segments[id]!;
const level = heat.level[id]!; const level = heat.level[id]!;
if (level === 'patrol') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'patrol', chatterRng); if (level === 'patrol') {
if (level === 'turret') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'engineer', chatterRng); 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 // Driving a road is how you learn what is on it, and seeing ground is how
// it stops being a blank on the map. // it stops being a blank on the map.
@ -442,17 +453,23 @@ async function boot() {
chatterRng, chatterRng,
); );
// Finished towers become solid; abandoned ones stop being solid. // Whatever an engineer just finished now exists in the world.
for (const id of unitEvents.built) { for (const { segment, stage } of unitEvents.built) {
const site = units.checkpoints.get(id); if (stage === 'barricade') {
if (!site || towerBodies.has(id)) continue; 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( towerBodies.set(
id, segment,
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }), 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); say('They have finished the tower on that road.', 6);
} }
for (const id of unitEvents.removed) { for (const id of unitEvents.removed) {
heatProps.abandon(id);
const body = towerBodies.get(id); const body = towerBodies.get(id);
if (!body) continue; if (!body) continue;
physics.removeBody(body); physics.removeBody(body);

View File

@ -122,7 +122,10 @@ export function createUnitView(scene: THREE.Scene) {
let towerCount = 0; let towerCount = 0;
for (const site of units.checkpoints.values()) { for (const site of units.checkpoints.values()) {
if (towerCount >= towers.count) break; 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); const height = built ? 1 : Math.max(0.15, site.progress);
position.set(site.x, (6 * height) / 2, site.z); position.set(site.x, (6 * height) / 2, site.z);
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1)); 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 { createHeat } from './heat';
import { freshCondition } from './car'; import { freshCondition } from './car';
import { import {
AMBIENT_PATROLS,
createUnits, createUnits,
dispatchTo, dispatchTo,
hasBuilt,
stepUnits, stepUnits,
PATROL_HEAT_DECAY, PATROL_HEAT_DECAY,
patrolProgress, patrolProgress,
@ -25,7 +27,11 @@ function run(
overrides: Partial<Parameters<typeof stepUnits>[1]> = {}, overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
rng = makeRng(3), 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++) { for (let i = 0; i < seconds * 10; i++) {
const step = stepUnits( const step = stepUnits(
state, 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 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(); const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
// Nothing is standing yet. // Dispatched, but nothing is standing on the road yet.
expect(state.checkpoints.size).toBe(0); expect(state.checkpoints.size).toBe(0);
const events = run(state, 200, { player: { x: segment.ax, z: segment.az } }); const events = run(state, 200, atSite);
expect(events.built).toContain(segment.id); expect(events.built).toContainEqual({ segment: segment.id, stage: 'barricade' });
const site = state.checkpoints.get(segment.id)!; expect(hasBuilt(state.checkpoints.get(segment.id), 'barricade')).toBe(true);
expect(site.built).toBe(true);
}); });
it('is manned once it stands', () => { it('does not raise a tower for a barricade job', () => {
const state = createUnits(); const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
run(state, 200, { player: { x: segment.ax, z: segment.az } }); 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'); const gunner = state.units.find((u) => u.role === 'garrison');
expect(gunner).toBeDefined(); expect(gunner).toBeDefined();
// Up on the tower, so it shoots over its own barricade. // 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', () => { it('is abandoned when the road stops being worth guarding', () => {
const state = createUnits(); const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2)); dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower');
run(state, 200, { player: { x: segment.ax, z: segment.az } }); run(state, 260, atSite);
expect(state.checkpoints.size).toBe(1); expect(state.checkpoints.size).toBe(1);
const events = run(state, 5, { const events = run(state, 5, { ...atSite, heatLevel: () => 'clear' });
player: { x: segment.ax, z: segment.az },
heatLevel: () => 'clear',
});
expect(events.removed).toContain(segment.id); expect(events.removed).toContain(segment.id);
expect(state.checkpoints.size).toBe(0); expect(state.checkpoints.size).toBe(0);
expect(state.units.some((u) => u.role === 'garrison')).toBe(false); 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', () => { describe('a war going on regardless', () => {
it('breaks out fights between the two armies', () => { it('breaks out fights between the two armies', () => {
const state = createUnits(); const state = createUnits();

View File

@ -55,17 +55,34 @@ export interface Unit {
cooldown: number; cooldown: number;
/** Height of the muzzle, so a gunner on a tower shoots over the barricade. */ /** Height of the muzzle, so a gunner on a tower shoots over the barricade. */
elevation: number; 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 { export interface Checkpoint {
segment: number; 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; progress: number;
built: boolean; /** Stages finished and standing. */
done: BuildStage[];
x: number; x: number;
z: number; z: number;
} }
export const hasBuilt = (site: Checkpoint | undefined, stage: BuildStage): boolean =>
site?.done.includes(stage) ?? false;
export interface UnitState { export interface UnitState {
units: Unit[]; units: Unit[];
checkpoints: Map<number, Checkpoint>; checkpoints: Map<number, Checkpoint>;
@ -98,11 +115,30 @@ const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
const PATROL_DURATION = 90; const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */ /** Heat removed per second by a patrol actually driving its assigned road. */
export const PATROL_HEAT_DECAY = 0.012; 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. */ /** Minimum gap between skirmishes breaking out. */
const SKIRMISH_SPACING = 55; 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 }; export const UNIT_HP: Record<UnitKind, number> = { car: 60, soldier: 30 };
// --- Helpers -------------------------------------------------------------- // --- Helpers --------------------------------------------------------------
@ -154,8 +190,18 @@ function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
return false; 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[] { 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 ------------------------------------------------------------- // --- Spawning -------------------------------------------------------------
@ -234,6 +280,7 @@ export function dispatchTo(
segment: RoadSegment, segment: RoadSegment,
role: 'patrol' | 'engineer', role: 'patrol' | 'engineer',
rng: Rng, rng: Rng,
stage: BuildStage = 'barricade',
): Unit | null { ): Unit | null {
if (state.dispatched.has(segment.id)) return 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. // the patrol arrives from somewhere plausible rather than materialising.
const origins = roads.nodes.filter((n) => { const origins = roads.nodes.filter((n) => {
const d = Math.hypot(n.x - segment.ax, n.z - segment.az); 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; if (origins.length === 0) return null;
const origin = origins[Math.floor(rng() * origins.length)]!; const origin = origins[Math.floor(rng() * origins.length)]!;
@ -265,9 +313,57 @@ export function dispatchTo(
onStation: 0, onStation: 0,
cooldown: 1, cooldown: 1,
elevation: 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. */ /** Two squads run into each other. The player is not invited. */
function spawnSkirmish( function spawnSkirmish(
state: UnitState, state: UnitState,
@ -330,9 +426,9 @@ export interface UnitStep {
} }
export interface UnitEvents { export interface UnitEvents {
/** Checkpoints finished this step, so the renderer can build them. */ /** Stages finished this step, so the world can put them up. */
built: number[]; built: Array<{ segment: number; stage: BuildStage }>;
/** Checkpoints abandoned, so the renderer can take them away. */ /** Checkpoints abandoned, so the world can take them away. */
removed: number[]; removed: number[];
skirmish: boolean; skirmish: boolean;
} }
@ -357,6 +453,12 @@ export function stepUnits(
spawnPedestrian(state, player, rng); 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 --- // --- A war going on regardless of the player ---
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) { if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
if (spawnSkirmish(state, roads, player, step.front, rng)) { if (spawnSkirmish(state, roads, player, step.front, rng)) {
@ -419,20 +521,32 @@ export function stepUnits(
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt); step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
if (unit.onStation > PATROL_DURATION) unit.expires = 0; if (unit.onStation > PATROL_DURATION) unit.expires = 0;
} else { } else {
const stage: BuildStage = unit.building ?? 'barricade';
const site = state.checkpoints.get(segment.id) ?? { const site = state.checkpoints.get(segment.id) ?? {
segment: segment.id, segment: segment.id,
stage,
progress: 0, progress: 0,
built: false, done: [],
x: point.x, x: point.x,
z: point.z, z: point.z,
}; };
state.checkpoints.set(segment.id, site); 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) { if (site.progress >= 1) {
site.built = true; site.done.push(stage);
events.built.push(segment.id); site.progress = 0;
// Someone has to man it once it stands. 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); const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5);
makeUnit(state, { makeUnit(state, {
kind: 'soldier', kind: 'soldier',
@ -451,8 +565,8 @@ export function stepUnits(
// Up on the tower, shooting over its own barricade. // Up on the tower, shooting over its own barricade.
elevation: 5, elevation: 5,
}); });
unit.expires = 0;
} }
unit.expires = 0;
} }
} }
break; break;
@ -503,7 +617,11 @@ export function stepUnits(
// --- Retire the dead, the finished and the far away --- // --- Retire the dead, the finished and the far away ---
const survivors: Unit[] = []; const survivors: Unit[] = [];
for (const unit of state.units) { 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.hp <= 0 || unit.expires <= 0 || tooFar) {
if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) { if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) {
state.dispatched.delete(unit.assigned); state.dispatched.delete(unit.assigned);