drive-between-the-lines/src/sim/units.test.ts
dejvino 5c2778785c Allied vehicles, with men in them
Your own side had people on foot and nothing on wheels, so home ground
had civilian traffic on it and nothing else moving that was yours. A
road with only civilians on it reads the same behind the lines as past
them.

Convoys route across the network like traffic - which means they
inherit lane discipline and following distance for nothing - but only
between junctions your side holds, and they leave when the line moves
over them. Fewer than the militia on foot, because a truck is scarcer
than a man with a rifle and because friendly traffic that outnumbered
the civilian kind would turn every road into a convoy. Across the map:
convoys 4, 2, 0, 0 and militia 14, 10, 3, 0 against patrols 0, 0, 3, 5.

They are insurgent faction, so they shoot and get shot at, and the
render already crews any armed vehicle - they carry the same visible
gunner the enemy cars got.

Two things this shook out. Lane keeping was building its axis from the
car-to-node direction, which is nearly right mid-leg and degenerates
completely near either end, where the direction swings ninety degrees
over a few metres; on a grid map cars spend most of their time near a
junction. Units now remember the junction they last left and drive the
leg they are actually on. And arrival was still judged on the node
itself while the car deliberately passes three metres to one side of
it, so on trunks dispatched units drove straight past their
destination - no patrol worked a road, no engineer poured any concrete.

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

896 lines
30 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_ALLY_CARS,
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, and far enough past your line that closing
// on each other cannot walk them back over it.
expect(control).not.toBe('liberated');
}
});
});
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, canSee: () => true },
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, canSee: () => true },
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),
canSee: () => true,
},
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, canSee: () => true },
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, canSee: () => true },
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()).toBeLessThanOrEqual(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);
});
});
describe('walls are real', () => {
/** A slab across the middle of the world, with open ground either side. */
const wall = (x: number, z: number) => Math.abs(x) < 40 && z > 30 && z < 60;
it('keeps people on foot out of the buildings', () => {
const state = createUnits();
run(state, 200, { player: { x: 0, z: 0 }, blocked: wall }, makeRng(12));
const onFoot = state.units.filter((u) => u.kind === 'soldier');
expect(onFoot.length).toBeGreaterThan(5);
for (const u of onFoot) expect(wall(u.x, u.z)).toBe(false);
});
it('does not let a chase cut through the middle of one', () => {
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;
for (let i = 0; i < 60 * 10; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: { x: 0, z: 300 },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: { x: 0, z: 300 },
blocked: wall,
},
world.roads,
graph,
makeRng(5),
);
expect(wall(unit.x, unit.z)).toBe(false);
}
});
});
describe('nobody fires at what they cannot see', () => {
/** Two hostiles in range of each other with something solid between them. */
const facingOff = () => {
const units = createUnits();
const place = (faction: 'enemy' | 'insurgent', x: number) => {
units.units.push({
id: units.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,
});
};
place('enemy', -20);
place('insurgent', 20);
return units;
};
const shotsOver = (canSee: () => boolean) => {
const combat = createCombat();
const units = facingOff();
let fired = 0;
for (let i = 0; i < 300; i++) {
const r = stepCombat(
combat,
units,
{
dt: 1 / 60,
player: { x: 999, z: 999 },
playerExposed: false,
blocked: () => false,
canSee,
},
freshCondition(),
makeRng(9),
);
fired += r.fired.length;
}
return fired;
};
it('holds fire through a wall, and opens up without one', () => {
// The rounds always died against the building. What nothing checked was
// whether to pull the trigger, so tracers appeared out of solid concrete.
expect(shotsOver(() => false)).toBe(0);
expect(shotsOver(() => true)).toBeGreaterThan(0);
});
});
describe('traffic that drives like traffic', () => {
/** Where each car sits relative to the centre of the road it is on. */
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
// Signed offset from the segment's centreline, positive to the car's right.
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
const px = unit.x - seg.ax;
const pz = unit.z - seg.az;
const lateral = px * Math.cos(along) - pz * Math.sin(along);
// Flip for cars travelling the other way down the same segment.
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
};
const traffic = () => {
const state = createUnits();
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(31));
return state.units.filter((u) => u.role === 'traffic');
};
it('keeps right rather than straddling the centreline', () => {
// Sampled over four minutes rather than from one frozen frame: at any one
// instant only a handful of cars are squarely mid-segment, and four cars is
// not a measurement.
const state = createUnits();
let onTheRight = 0;
let judged = 0;
for (let i = 0; i < 2400; i++) {
stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(31),
);
if (i % 10 !== 0) continue;
for (const car of state.units) {
if (car.role !== 'traffic' && car.role !== 'convoy') continue;
const seg = world.roads.segments.find((s) => {
const t =
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
// Well inside the segment: at a junction there is no side to be on.
if (t < 0.25 || t > 0.75) return false;
const cx = s.ax + (s.bx - s.ax) * t;
const cz = s.az + (s.bz - s.az) * t;
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
});
if (!seg) continue;
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
// Only cars travelling along this road, not crossing it.
if (Math.abs(Math.cos(car.heading - along)) < 0.9) continue;
judged++;
if (sideOfRoad(car, seg) > 0) onTheRight++;
}
}
expect(judged).toBeGreaterThan(200);
expect(onTheRight / judged).toBeGreaterThan(0.8);
});
it('does not drive through the car in front', () => {
const cars = traffic();
for (const car of cars) {
for (const other of cars) {
if (other === car) continue;
// Nose to nose. Cars passing in opposite directions are fine — they are
// on opposite sides of the road — so only same-direction pairs count.
if (Math.cos(other.heading - car.heading) < 0.5) continue;
expect(Math.hypot(other.x - car.x, other.z - car.z)).toBeGreaterThan(1.5);
}
}
});
it('turns the nose rather than snapping it', () => {
const state = createUnits();
let worst = 0;
const before = new Map<number, number>();
for (let i = 0; i < 60 * 90; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(17),
);
for (const u of state.units) {
if (u.role !== 'traffic') continue;
const was = before.get(u.id);
if (was !== undefined) {
let delta = Math.abs(u.heading - was);
if (delta > Math.PI) delta = Math.PI * 2 - delta;
worst = Math.max(worst, delta);
}
before.set(u.id, u.heading);
}
}
// A right-angle junction used to be one frame of instant pivot. Nothing
// should now turn faster than a car can be steered.
expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6);
});
});
describe('your own side, with vehicles', () => {
const deepIn = (control: 'liberated' | 'contested' | 'occupied') => {
const wanted = { liberated: -140, contested: 40, occupied: 240 }[control];
const depth = front.boundaries.liberated + wanted;
return { x: front.axis.x * depth, z: front.axis.z * depth };
};
const convoys = (control: 'liberated' | 'contested' | 'occupied') => {
const state = createUnits();
run(state, 200, { player: deepIn(control) }, makeRng(8));
return state.units.filter((u) => u.role === 'convoy');
};
it('puts allied vehicles on ground your side holds', () => {
expect(convoys('liberated').length).toBeGreaterThan(0);
expect(convoys('liberated').length).toBeLessThanOrEqual(AMBIENT_ALLY_CARS.liberated);
});
it('stops at the line, like everything else of yours', () => {
expect(convoys('occupied').length).toBe(0);
});
it('never routes one onto ground your side does not hold', () => {
const state = createUnits();
run(state, 200, { player: deepIn('contested') }, makeRng(8));
for (const u of state.units.filter((c) => c.role === 'convoy')) {
const control = controlAt(front, u.x, u.z);
expect(control === 'liberated' || control === 'contested').toBe(true);
}
});
it('carries a crew that the enemy will shoot at', () => {
// Insurgent faction, so nearestHostile treats them as a target and they
// treat the enemy as one — a convoy is not scenery.
const state = createUnits();
run(state, 200, { player: deepIn('liberated') }, makeRng(8));
for (const u of state.units.filter((c) => c.role === 'convoy')) {
expect(u.faction).toBe('insurgent');
expect(u.kind).toBe('car');
// Rounds leave the man riding in it, not the bonnet.
expect(u.elevation).toBeGreaterThan(1.2);
}
});
});