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>
This commit is contained in:
dejvino 2026-08-09 07:27:54 +02:00
parent 759399c53c
commit baf534c4c4
2 changed files with 187 additions and 3 deletions

View File

@ -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);
});
});

View File

@ -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<Control, number> = {
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<Control, number> = {
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<UnitKind, number> = { 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) {