diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index a49d117..87e7e73 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -6,6 +6,7 @@ import { createFront, controlAt } from './regions'; import { createHeat, createAreas } from './heat'; import { freshCondition } from './car'; import { + AMBIENT_ALLIES, AMBIENT_PATROLS, createUnits, dispatchTo, @@ -556,3 +557,73 @@ describe('a hunter', () => { 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); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index 9885934..4fc5ca3 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -31,7 +31,9 @@ export type Role = /** Stands on a finished checkpoint and shoots. */ | 'garrison' /** Fighting the other army. */ - | 'fighter'; + | 'fighter' + /** Your own side, holding the ground it holds. Thick at home, thin at the line. */ + | 'militia'; export interface Unit { id: number; @@ -177,6 +179,44 @@ export const AMBIENT_PATROLS: Record = { frontier: 5, }; +/** + * Your own people on the ground, by whose ground it is. + * + * The mirror of AMBIENT_PATROLS, and deliberately the other way up. 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 made the + * palette do 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. + * + * Thick at home and thinning fast, so the gradient itself is information. Past + * the contested band there are none: that is what "past the line" means, and a + * friendly face on the frontier would be a safety net the place is not supposed + * to have. + * + * There is a second, quieter consequence. Pursuit calls a chase off when a + * hunter has insurgents on its doorstep, so running for your own lines now + * genuinely works — the closer you get to home the more likely they peel off. + * That was already the rule; there was simply nobody around to trigger it. + */ +export const AMBIENT_ALLIES: Record = { + liberated: 14, + contested: 5, + occupied: 0, + frontier: 0, +}; + +/** + * How far from the player a militiaman is still worth simulating. + * + * Far tighter than SIM_RADIUS, and past the fog in every band, so they leave + * out of sight rather than popping. They are ambience with no job to travel + * to: keeping them to the general cull radius meant a crowd of friendly faces + * followed the player half a kilometre into occupied ground. + */ +const MILITIA_RADIUS = 380; + export const UNIT_HP: Record = { car: 60, soldier: 30 }; // --- Helpers -------------------------------------------------------------- @@ -402,6 +442,49 @@ function spawnAmbientPatrol( return true; } +/** + * One of yours, on foot, on ground your side holds. + * + * Placed by walking out from the player and checking who holds where they would + * stand, rather than by trusting the band the player is in: near a border those + * are different answers, and a militiaman standing fifty metres inside occupied + * ground is exactly the thing that would make the front unreadable. + */ +function spawnAlly( + state: UnitState, + near: { x: number; z: number }, + front: Front, + rng: Rng, +): boolean { + for (let attempt = 0; attempt < 8; attempt++) { + const angle = rng() * Math.PI * 2; + const radius = 70 + rng() * 230; + const x = near.x + Math.cos(angle) * radius; + const z = near.z + Math.sin(angle) * radius; + const control = controlAt(front, x, z); + if (control !== 'liberated' && control !== 'contested') continue; + + makeUnit(state, { + kind: 'soldier', + faction: 'insurgent', + role: 'militia', + x, + z, + heading: rng() * Math.PI * 2, + speed: SPEED.soldier * (0.5 + rng() * 0.5), + hp: UNIT_HP.soldier, + path: [], + expires: 400 + rng() * 300, + assigned: null, + onStation: 0, + cooldown: rng(), + elevation: 1.2, + }); + return true; + } + return false; +} + /** Two squads run into each other. The player is not invited. */ function spawnSkirmish( state: UnitState, @@ -507,11 +590,18 @@ export function stepUnits( } // --- Enemy ground is patrolled because it is enemy ground --- + const here = controlAt(step.front, player.x, player.z); 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) { + if (patrols < AMBIENT_PATROLS[here] && rng() < dt * 1.5) { spawnAmbientPatrol(state, roads, player, step.front, rng); } + // --- And your own ground has your own people standing in it --- + const allies = state.units.filter((u) => u.role === 'militia').length; + if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) { + spawnAlly(state, player, step.front, rng); + } + // --- A war going on regardless of the player --- if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) { if (spawnSkirmish(state, roads, player, step.front, rng)) { @@ -667,7 +757,8 @@ export function stepUnits( break; } - case 'fighter': { + case 'fighter': + case 'militia': { // Close on the nearest enemy of the other army and stand your ground. const enemy = nearestHostile(state, unit, 140); if (enemy) { @@ -677,7 +768,16 @@ export function stepUnits( unit.x += Math.sin(unit.heading) * unit.speed * dt; unit.z += Math.cos(unit.heading) * unit.speed * dt; } + break; } + // Nothing to shoot at. Militia are holding ground rather than taking + // it, so they drift about it instead of standing to attention — a + // street of statues reads as scenery, which is the opposite of the + // point of putting them there. + if (unit.role !== 'militia') break; + if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6; + unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt; + unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * dt; break; } @@ -709,6 +809,19 @@ export function stepUnits( void site; } + // --- Ground your side no longer holds has none of your people standing on it --- + // The line moves on its own, and when it moves over somebody they are not + // there any more. It also keeps the population honest as the player advances: + // militia are local, so they are culled at a far tighter radius than anything + // with a job to do, and driving deep into occupied ground leaves them behind + // rather than towing a friendly crowd along the front. + for (const unit of state.units) { + if (unit.role !== 'militia') continue; + const control = controlAt(step.front, unit.x, unit.z); + if (control !== 'liberated' && control !== 'contested') unit.expires = 0; + if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0; + } + // --- Retire the dead, the finished and the far away --- const survivors: Unit[] = []; for (const unit of state.units) {