drive-between-the-lines/src/sim/units.test.ts
dejvino baf534c4c4 Your own people, standing in your own streets
Liberated territory read as empty. No patrols by design, and nobody
else either - so the safest part of the map was also the deadest, and
coming home felt like arriving nowhere rather than arriving somewhere
held. It also left the palette doing all the work of telling the bands
apart, when the population is the more legible signal: who is standing
in the street tells you whose street it is.

Militia mirror AMBIENT_PATROLS the other way up - thick at home,
thinning fast, none past the contested band, because a friendly face on
the frontier would be a safety net the setting is not supposed to have.
Driving across the map now reads 14, 14, 11, 5, 0 against enemies 0, 0,
3, 4, 4.

They are placed by checking who holds the ground they would stand on
rather than the band the player is in - near a border those are
different answers - and they leave when the line moves over them, or
when the player has driven far enough that they are only ambience
following him about.

There is a quieter consequence. Pursuit already called a chase off when
a hunter had insurgents on its doorstep, so running for your own lines
now genuinely works. The rule existed; there was nobody around to
trigger it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:27:54 +02:00

630 lines
21 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { makeRng } from '../core/rng';
import { generateWorld } from './world';
import { buildGraph } from './routing';
import { createFront, controlAt } from './regions';
import { createHeat, createAreas } from './heat';
import { freshCondition } from './car';
import {
AMBIENT_ALLIES,
AMBIENT_PATROLS,
createUnits,
dispatchTo,
hasBuilt,
stepUnits,
PATROL_HEAT_DECAY,
PEDESTRIAN_TARGET,
patrolProgress,
TRAFFIC_TARGET,
type UnitState,
} from './units';
import { createCombat, segmentHits, stepCombat } from './combat';
const world = generateWorld(1337);
const graph = buildGraph(world.roads);
const front = createFront(1337, world.extent, world.spawn);
const areas = createAreas(world.roads, world.extent);
function run(
state: UnitState,
seconds: number,
overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
rng = makeRng(3),
) {
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,
{
dt: 0.1,
now: i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'turret',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
...overrides,
},
world.roads,
graph,
rng,
);
events.built.push(...step.built);
events.removed.push(...step.removed);
events.skirmish ||= step.skirmish;
}
return events;
}
describe('a world with people in it', () => {
it('puts traffic on the roads and people on foot', () => {
const state = createUnits();
run(state, 40);
expect(state.units.filter((u) => u.role === 'traffic').length).toBeGreaterThan(3);
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeGreaterThan(2);
});
it('does not let the population grow without bound', () => {
const state = createUnits();
run(state, 400);
// Asserted against the targets themselves rather than hardcoded numbers,
// so tuning the density does not break a test about it being bounded.
expect(state.units.filter((u) => u.role === 'traffic').length).toBeLessThanOrEqual(
TRAFFIC_TARGET,
);
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeLessThanOrEqual(
PEDESTRIAN_TARGET,
);
});
it('cleans up anything that wanders too far from the player', () => {
const state = createUnits();
run(state, 60);
expect(state.units.length).toBeGreaterThan(0);
// Move the player to the far corner. Everything left behind is retired —
// though fresh traffic will of course appear around wherever they now are.
run(state, 30, { player: { x: 100000, z: 100000 } });
const nearOldPosition = state.units.filter(
(u) => Math.hypot(u.x - world.spawn.x, u.z - world.spawn.z) < 5000,
);
expect(nearOldPosition).toHaveLength(0);
});
});
describe('patrols are dispatched, not conjured', () => {
const segment = world.roads.segments[4]!;
it('starts somewhere else and has to drive to the road', () => {
const state = createUnits();
const unit = dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))!;
expect(unit).not.toBeNull();
// It begins well away from its assignment, with a route to follow.
expect(Math.hypot(unit.x - segment.ax, unit.z - segment.az)).toBeGreaterThan(140);
expect(unit.path.length).toBeGreaterThan(0);
expect(unit.assigned).toBe(segment.id);
});
it('does not send a second patrol to a road that already has one coming', () => {
const state = createUnits();
expect(dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))).not.toBeNull();
expect(dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))).toBeNull();
});
it('arrives, works the road, and cools it down', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
let cooled = 0;
run(state, 120, {
player: { x: segment.ax, z: segment.az },
decayHeat: (id, amount) => {
expect(id).toBe(segment.id);
cooled += amount;
},
});
// A patrol on station is the thing that brings heat back down.
expect(cooled).toBeGreaterThan(PATROL_HEAT_DECAY * 20);
});
it('settles the district it is working, but only barely', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
let road = 0;
let area = 0;
run(state, 120, {
player: { x: segment.ax, z: segment.az },
decayHeat: (_, amount) => {
road += amount;
},
decayArea: (_x, _z, amount) => {
area += amount;
},
});
expect(area).toBeGreaterThan(0);
// A patrol re-secures the road it was sent to. It barely touches how the
// district feels about you — otherwise waiting out a patrol would launder
// your reputation, and district heat is meant to be the part you cannot
// patrol away.
expect(area).toBeLessThan(road / 5);
});
it('leaves once it has done its rounds', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
run(state, 260, { player: { x: segment.ax, z: segment.az } });
expect(state.units.some((u) => u.role === 'patrol')).toBe(false);
// And the road is free to be patrolled again later.
expect(state.dispatched.has(segment.id)).toBe(false);
});
it('sweeps back and forth rather than parking', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
run(state, 60, { player: { x: segment.ax, z: segment.az } });
const patrol = state.units.find((u) => u.role === 'patrol');
if (!patrol) return;
const seen = new Set<string>();
for (let i = 0; i < 200; i++) {
run(state, 1, { player: { x: segment.ax, z: segment.az } });
const live = state.units.find((u) => u.role === 'patrol');
if (live) seen.add(patrolProgress(live, segment).toFixed(1));
}
expect(seen.size).toBeGreaterThan(2);
});
});
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('pours no concrete until an engineer has stood there long enough', () => {
const state = createUnits();
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, atSite);
expect(events.built).toContainEqual({ segment: segment.id, stage: 'barricade' });
expect(hasBuilt(state.checkpoints.get(segment.id), 'barricade')).toBe(true);
});
it('does not raise a tower for a barricade job', () => {
const state = createUnits();
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.
expect(gunner!.elevation).toBeGreaterThan(3);
});
it('is abandoned when the road stops being worth guarding', () => {
const state = createUnits();
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, { ...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();
const events = run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
expect(events.skirmish).toBe(true);
const fighters = state.units.filter((u) => u.role === 'fighter');
expect(fighters.length).toBeGreaterThan(1);
expect(new Set(fighters.map((f) => f.faction)).size).toBe(2);
});
it('only fights over ground that is actually contested', () => {
const state = createUnits();
run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
for (const fighter of state.units.filter((u) => u.role === 'fighter')) {
const control = controlAt(front, fighter.x, fighter.z);
// Spawned where the war is — they may drift, but not from behind a line.
expect(['contested', 'occupied']).toContain(control);
}
});
});
describe('rounds in flight', () => {
it('catches a target the round passed straight through between steps', () => {
// At 260 m/s a round covers four metres a step; endpoint checks would miss.
expect(segmentHits(0, 0, 10, 0, 5, 0.5, 1.6)).toBe(true);
expect(segmentHits(0, 0, 10, 0, 5, 4, 1.6)).toBe(false);
});
const shooters = () => {
const state = createUnits();
for (const [faction, x] of [
['enemy', 0],
['insurgent', 30],
] as const) {
state.units.push({
id: state.nextId++,
kind: 'soldier',
faction,
role: 'fighter',
x,
z: 0,
heading: 0,
speed: 0,
hp: 30,
path: [],
expires: 999,
assigned: null,
onStation: 0,
cooldown: 0,
elevation: 1.2,
});
}
return state;
};
it('puts rounds in the air when two sides are in range', () => {
const units = shooters();
const combat = createCombat();
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false },
freshCondition(),
makeRng(1),
);
expect(combat.rounds.length).toBeGreaterThan(0);
});
it('lets a stray round hit a bystander who never joined in', () => {
const units = shooters();
const combat = createCombat();
const rng = makeRng(5);
let condition = freshCondition();
let hit = false;
// The player parked directly between two squads shooting at each other.
for (let i = 0; i < 600 && !hit; i++) {
const result = stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false },
condition,
rng,
);
condition = result.condition;
hit ||= result.playerHit;
}
expect(hit).toBe(true);
// And it costs something permanent, like every other kind of damage.
expect(condition.ceiling.tires).toBeLessThan(1);
});
it('stops rounds at walls, so cover is worth something', () => {
const units = shooters();
const combat = createCombat();
let condition = freshCondition();
let hit = false;
for (let i = 0; i < 600 && !hit; i++) {
const result = stepCombat(
combat,
units,
{
dt: 1 / 60,
player: { x: 15, z: 0 },
playerExposed: false,
// Walls between the player and each squad, so nothing has a line.
blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22),
},
condition,
makeRng(5),
);
condition = result.condition;
hit ||= result.playerHit;
}
expect(hit).toBe(false);
});
it('does not shoot at an undercover driver who has done nothing', () => {
const units = createUnits();
units.units.push({
id: 1,
kind: 'soldier',
faction: 'enemy',
role: 'garrison',
x: 0,
z: 0,
heading: 0,
speed: 0,
hp: 60,
path: [],
expires: 999,
assigned: 0,
onStation: 0,
cooldown: 0,
elevation: 5,
});
const combat = createCombat();
for (let i = 0; i < 300; i++) {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false },
freshCondition(),
makeRng(9),
);
}
expect(combat.rounds.length).toBe(0);
});
it('does shoot once the player is a known quantity on that road', () => {
const units = createUnits();
units.units.push({
id: 1,
kind: 'soldier',
faction: 'enemy',
role: 'garrison',
x: 0,
z: 0,
heading: 0,
speed: 0,
hp: 60,
path: [],
expires: 999,
assigned: 0,
onStation: 0,
cooldown: 0,
elevation: 5,
});
const combat = createCombat();
let fired = 0;
for (let i = 0; i < 300; i++) {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false },
freshCondition(),
makeRng(9),
);
// Counted as they are fired: rounds expire in under half a second, so
// checking the tally at the end would find an empty sky.
fired = Math.max(fired, combat.rounds.length);
}
expect(fired).toBeGreaterThan(0);
});
});
describe('heat and units together', () => {
it('leaves heat alone when no patrol has arrived', () => {
const heat = createHeat(world.roads, areas);
heat.value[4] = 0.5;
const state = createUnits();
dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7));
// Still driving here; nothing has been re-secured yet.
run(state, 3, {
player: { x: world.spawn.x, z: world.spawn.z },
decayHeat: () => expect.unreachable('patrol has not arrived yet'),
});
expect(heat.value[4]).toBe(0.5);
});
});
describe('a hunter', () => {
/** One enemy car, chasing, some way off to one side. */
const chaser = () => {
const state = createUnits();
const unit = dispatchTo(
state,
world.roads,
graph,
world.roads.segments[4]!,
'patrol',
makeRng(7),
)!;
unit.x = 0;
unit.z = 0;
unit.path = [];
unit.hunting = true;
return { state, unit };
};
const chase = (
state: ReturnType<typeof createUnits>,
seconds: number,
target: { x: number; z: number },
blocked?: (x: number, z: number) => boolean,
) => {
for (let i = 0; i < seconds * 60; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: target,
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: target,
blocked,
},
world.roads,
graph,
makeRng(5),
);
}
};
it('drives faster chasing than it does on its rounds', () => {
const { state, unit } = chaser();
const patrolSpeed = unit.speed;
chase(state, 6, { x: 0, z: 600 });
// Covered more ground than a patrol at its working pace ever could, and
// still nowhere near the 29 m/s a healthy car will do on a clear run.
expect(unit.z).toBeGreaterThan(patrolSpeed * 6);
expect(unit.z).toBeLessThan(29 * 6);
});
it('winds up rather than starting at chase speed', () => {
const { state, unit } = chaser();
chase(state, 0.25, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeLessThan(21);
chase(state, 5, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeCloseTo(21, 1);
});
it('goes round a building instead of through it', () => {
const { state, unit } = chaser();
// A wall straight across the path, with open ground either side of it.
const wall = (x: number, z: number) => z > 40 && z < 60 && Math.abs(x) < 30;
chase(state, 12, { x: 0, z: 300 }, wall);
expect(wall(unit.x, unit.z)).toBe(false);
// It went round the end rather than sitting against the face of it.
expect(Math.abs(unit.x)).toBeGreaterThan(10);
});
it('drops back to its working pace once the chase is off', () => {
const { state, unit } = chaser();
chase(state, 5, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeGreaterThan(unit.speed);
unit.hunting = false;
chase(state, 1, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeUndefined();
});
});
describe('your own side, standing in your own streets', () => {
/**
* Somewhere well inside a given band, so the answer is about that band and
* not about the player happening to sit on a border.
*/
const deepIn = (control: 'liberated' | 'contested' | 'occupied') => {
const wanted = { liberated: -140, contested: 40, occupied: 240 }[control];
const depth = front.boundaries.liberated + wanted;
return { x: front.axis.x * depth, z: front.axis.z * depth };
};
const populate = (control: 'liberated' | 'contested' | 'occupied') => {
const state = createUnits();
const player = deepIn(control);
run(state, 120, { player }, makeRng(21));
return state.units.filter((u) => u.role === 'militia');
};
it('is thick at home and thin at the line', () => {
const home = populate('liberated').length;
const near = populate('contested').length;
expect(home).toBeGreaterThan(AMBIENT_ALLIES.liberated / 2);
expect(near).toBeGreaterThan(0);
expect(near).toBeLessThan(home);
});
it('stops at the line, because that is what the line means', () => {
expect(populate('occupied').length).toBe(0);
});
it('never stands anyone past the front, wherever the player happens to be', () => {
// Spawned near the player, so a player sitting on a border must not put
// friendly faces down on the wrong side of it.
const state = createUnits();
run(state, 120, { player: deepIn('contested') }, makeRng(4));
for (const u of state.units.filter((m) => m.role === 'militia')) {
const control = controlAt(front, u.x, u.z);
expect(control === 'liberated' || control === 'contested').toBe(true);
}
});
it('does not tow a friendly crowd along behind you into occupied ground', () => {
// Population has to thin as you advance, or the gradient carries no
// information at all: you would simply keep whatever you set off with.
const militia = () => state.units.filter((u) => u.role === 'militia').length;
const state = createUnits();
run(state, 120, { player: deepIn('liberated') }, makeRng(21));
const home = militia();
expect(home).toBeGreaterThan(4);
// Just past the line, some of the ones you set off with are still behind
// you in contested ground and legitimately still there.
run(state, 60, { player: deepIn('occupied') }, makeRng(21));
expect(militia()).toBeLessThan(home / 2);
// Out on the frontier there is nobody at all. That is what past the line
// means, and it is the one place a friendly face would be a safety net the
// setting is not supposed to have.
const depth = front.boundaries.occupied + 200;
run(state, 60, { player: { x: front.axis.x * depth, z: front.axis.z * depth } }, makeRng(21));
expect(militia()).toBe(0);
});
it('is what lets a chase be broken up on the way home', () => {
// Pursuit calls a chase off when a hunter has insurgents on its doorstep.
// That rule existed already; there was simply nobody around to trigger it.
expect(populate('liberated').length).toBeGreaterThan(0);
});
});