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[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(); 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, 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); }); });