Districts that remember you, and roads worth staying on

Area heat. Road heat alone rewarded a strange kind of play: work one part of
town hard but take a different street every time, and nothing ever got hot. A
second, coarser pool now accrues from being inside a district at all — on-road
or off — and bleeds into every road running through it, including ones never
driven. Deliberately capped below the barricade threshold: a hot district gets
roads patrolled, but concrete still takes somebody actually using that road. It
also decays slower than road heat, because one patrol re-secures a road while a
district's reputation lingers.

Surfaces. Off the route the car now makes about 40% of its road top speed, loses
grip and scrubs momentum in a fifth of the distance, so the road hierarchy is
something you feel rather than something the map merely claims. A first tuning
made off-road a 12 km/h crawl, which removed the choice instead of pricing it —
going round a checkpoint cross-country has to stay a legitimate move.

Speed. Heat scales with how fast you were going, not just how far, and stops
rewarding ever-higher speed past a point so the answer is never one exact number
on the clock. With the road classes, that makes the routing decision real: the
trunk fast and noticed, or the lanes slow and ignored.

Save format goes to v2 for the district pool; older saves are refused rather
than half-restored. Also fixes a population test that hardcoded densities since
tuned, and README claims about patrols and AI that Phase 6 made untrue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-08 12:06:59 +02:00
parent 9ae00dc978
commit 9eaa05d7ef
15 changed files with 443 additions and 85 deletions

View File

@ -50,14 +50,45 @@ road everywhere sheds it slowly. Cross a threshold and the road escalates:
`Clear → Patrol → Barricade → Turret`
- **Patrol** parks a vehicle on the verge. The road narrows.
- **Barricade** puts concrete across it, leaving a gap you have to slow for.
- **Turret** adds a tower overlooking the checkpoint.
- **Patrol** sends a car to work the road for a while, which cools it back down.
- **Barricade** sends an engineer to pour concrete across it, leaving a gap you
have to slow for.
- **Turret** sends another to put up a tower, and a gunner to man it.
None of that appears on its own — see [A world with people in it](#a-world-with-people-in-it).
Roughly four traversals take a road from clear to turret; an untouched road
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
guesses meant to be tuned.
### District heat
Roads remember being used. Districts remember *you*.
Road heat alone rewards a strange kind of play: work one part of town hard but
take a different street every time, and nothing ever gets hot. So there is a
second, coarser pool. Every metre you cover inside a district raises its
standing — on-road or off, whether or not you touch a road at all — and that
bleeds into every road running through it, including ones you have never driven.
- It **accrues from being there**, so cutting across country is not a loophole.
- It **cannot fortify a road on its own** (`AREA_INFLUENCE` sits below the
barricade threshold). A hot district gets roads patrolled; concrete still
takes somebody actually using that road.
- It **outlasts road heat**. A road is re-secured by one patrol; a district's
reputation takes far longer to fade.
### Speed is conspicuous
Heat scales with how fast you were going, not just how far. A car at walking
pace is traffic; a car doing ninety through a checkpointed district is the thing
people phone in. Roughly a 2.2× spread between crawling and flat out, and it
stops rewarding ever-higher speed past a point so the answer is never one exact
number on the clock.
Together with the road classes this is the real routing decision: the trunk fast
and noticed, or the lanes slow and ignored.
### The road hierarchy
Roads are not all the same road:
@ -96,6 +127,15 @@ while leaving most of the map genuinely off-route. Widening it backfires: make
the corridor too generous and heat becomes unavoidable everywhere, and "take a
different route" stops being a choice at all.
### Tarmac versus everything else
Roads have to be worth using, or the hierarchy above is decoration. Off the
route the car makes about 40% of its road top speed, loses grip, and scrubs off
momentum in a fifth of the distance. It is a real cost, deliberately not a wall:
going around a checkpoint cross-country is a legitimate move, and an earlier
tuning that made off-road a 12 km/h crawl removed the choice rather than pricing
it.
### Buildings, and why they are dense
Off-road used to be open country, which made every barricade optional — you just
@ -107,13 +147,9 @@ free detour at speed.
### Other deliberate choices
- **No AI yet.** The props are stationary hazards. Escalation currently means the
road gets slower and more expensive to get wrong, which is enough to test the
phase's real question.
- **Changes never happen on the road you are on.** They are deferred until you
leave. Otherwise a barricade would spawn a static collider inside your car —
and you are supposed to discover escalation by *coming back*, not by watching
it assemble behind you.
- **Concrete never closes around the car standing in the gap.** A block spawning
on top of you would put a static collider inside the chassis, so the last slab
waits until you move.
- **A checkpoint's position is seeded from its segment**, so a given stretch of
road always fortifies in the same place. Recognising it is part of learning
the map.

View File

@ -1,7 +1,7 @@
import * as THREE from 'three';
import { generateWorld } from './sim/world';
import { applyWear, deriveHandling, freshCondition, repair } from './sim/car';
import { createHeat, stepHeat } from './sim/heat';
import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car';
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
import { baseAt, placeBases } from './sim/bases';
import { buildGraph } from './sim/routing';
@ -93,7 +93,8 @@ async function boot() {
const hud = createHud(seed);
const board = createBoard();
const driveState = createDriveState();
const heat = createHeat(model.roads);
const areas = createAreas(model.roads, model.extent);
const heat = createHeat(model.roads, areas);
const heatProps = createHeatProps(model.roads, physics, view.scene);
const intel = createIntel(model.roads, model.extent);
const quests = createQuests();
@ -184,7 +185,12 @@ async function boot() {
};
// --- Persistence ---
const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length);
const persistence = createPersistence(
seed,
model.roads.segments.length,
intel.explored.length,
heat.area.length,
);
const snapshot = (): Snapshot => {
const t = physics.chassis.translation();
@ -287,7 +293,9 @@ async function boot() {
if (cmd.respawn && !respawnLatch) physics.respawn();
respawnLatch = cmd.respawn;
drive(physics, driveState, cmd, deriveHandling(condition), dt);
// Tarmac or open ground — decided last step, since the road lookup needs
// a position and the car has not moved yet this one.
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
physics.step(dt);
captureChassis();
@ -320,19 +328,21 @@ async function boot() {
const heatChanged = stepHeat(heat, {
dt,
segmentId: currentSegment,
areaId: areaAt(areas, at.x, at.z),
distance,
// Who holds the ground, times how conspicuous this class of road is.
// A farm track behind the lines costs nothing; a trunk past the front
// is the most watched thing you can drive on.
accrual:
HEAT_MULTIPLIER[control] *
speedAttention(speed) *
(currentSegment === null
? 1
: ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]),
// Each road decays according to whoever holds *it*, not whoever holds
// the ground the car happens to be standing on.
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
});
}, areas);
heatProps.sync({ x: at.x, z: at.z });
// Escalation is entirely something the enemy has to *do*. Every level
@ -663,7 +673,8 @@ async function boot() {
heat: {
segmentId: currentSegment,
onTarmac,
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
value: currentSegment === null ? 0 : effectiveHeat(heat, areas, currentSegment),
area: heat.area[areaAt(areas, p.x, p.z) ?? 0] ?? 0,
level: currentSegment === null ? null : heat.level[currentSegment]!,
},
quest:
@ -699,7 +710,7 @@ async function boot() {
view,
physics,
// Live state, so a script can find something interesting and go look at it.
state: { units, combat, heat, intel, quests, front, model, bases },
state: { units, combat, heat, areas, intel, quests, front, model, bases },
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);

View File

@ -14,7 +14,7 @@ export const AUTOSAVE_INTERVAL = 20;
const keyFor = (seed: number) => `dbtl.save.${seed}`;
export function createPersistence(seed: number, segments: number, cells: number) {
export function createPersistence(seed: number, segments: number, cells: number, areas: number) {
let lastSave = 0;
let failed = false;
@ -39,7 +39,7 @@ export function createPersistence(seed: number, segments: number, cells: number)
const raw = localStorage.getItem(keyFor(seed));
if (!raw) return null;
const data: unknown = JSON.parse(raw);
if (!isLoadable(data, seed, segments, cells)) {
if (!isLoadable(data, seed, segments, cells, areas)) {
// A save from an older format or an older world generator. Dropping
// it is correct — restoring half of it would be worse.
localStorage.removeItem(keyFor(seed));

View File

@ -1,5 +1,5 @@
import type { DriverInput } from '../core/input';
import type { Handling } from '../sim/car';
import { TARMAC, type Handling, type Surface } from '../sim/car';
import type { PhysicsWorld } from './physics';
import { WHEELS } from '../carSpec';
@ -36,6 +36,7 @@ export function drive(
input: DriverInput,
handling: Handling,
dt: number,
surface: Surface = TARMAC,
): void {
const { vehicle } = physics;
const speed = vehicle.currentVehicleSpeed();
@ -54,13 +55,16 @@ export function drive(
// Throttle vs. brake: pressing back while rolling forward is braking, not reverse.
const wantsReverse = input.throttle < 0;
const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1);
const engineForce = braking ? 0 : input.throttle * handling.engineForce;
// Off the tarmac the engine is fighting the ground for traction.
const engineForce = braking ? 0 : input.throttle * handling.engineForce * surface.drive;
let brakeForce: number;
if (input.handbrake) brakeForce = handling.brakeForce * 1.6;
else if (braking) brakeForce = handling.brakeForce;
else if (input.throttle === 0) brakeForce = handling.coastBrake;
else brakeForce = 0;
// Rough ground drags on you whether or not you are asking it to.
brakeForce += handling.coastBrake * surface.drag;
// At a crawl with no throttle, hold the car still instead of letting it creep.
// Otherwise "stop the car" for a mission is a fight against a rolling wreck.
@ -74,7 +78,7 @@ export function drive(
vehicle.setWheelEngineForce(i, w.driven ? engineForce : 0);
// Handbrake locks the rear only — that is where the rotation comes from.
vehicle.setWheelBrake(i, input.handbrake && w.steered ? 0 : brakeForce);
vehicle.setWheelFrictionSlip(i, handling.frictionSlip);
vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness);
vehicle.setWheelFrictionSlip(i, handling.frictionSlip * surface.grip);
vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness * surface.grip);
}
}

View File

@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest';
import { createPhysics } from './physics';
import { createDriveState, drive } from './drive';
import { deriveHandling, freshCondition } from '../sim/car';
import {
deriveHandling,
freshCondition,
ROUGH,
surfaceFor,
TARMAC,
type Surface,
} from '../sim/car';
import type { DriverInput } from '../core/input';
import { generateWorld } from '../sim/world';
@ -170,6 +177,41 @@ describe('taking a junction', () => {
});
});
describe('roads are worth using', () => {
/** Flat out on a given surface for long enough to find its ceiling. */
async function topSpeed(surface: Surface) {
const physics = await createPhysics(generateWorld(1, 0));
const state = createDriveState();
const handling = deriveHandling(freshCondition());
let top = 0;
for (let i = 0; i < 30 / STEP; i++) {
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP, surface);
physics.step(STEP);
top = Math.max(top, physics.vehicle.currentVehicleSpeed());
}
return top;
}
it('goes markedly faster on tarmac than across country', async () => {
const road = await topSpeed(TARMAC);
const rough = await topSpeed(ROUGH);
expect(rough).toBeLessThan(road * 0.6);
});
it('still lets you leave the road when you need to', async () => {
// Going around a checkpoint cross-country has to cost something without
// being impossible. An earlier tuning made off-road a 12 km/h crawl, which
// is not a decision — it is a wall.
const rough = await topSpeed(ROUGH);
expect(rough).toBeGreaterThan(12);
});
it('is chosen by whether the car is on a route at all', () => {
expect(surfaceFor(true)).toBe(TARMAC);
expect(surfaceFor(false)).toBe(ROUGH);
});
});
describe('vehicle', () => {
it('settles on its suspension instead of sinking or bouncing away', async () => {
const r = await run({}, 2);

View File

@ -69,6 +69,35 @@ export function deriveHandling(c: CarCondition): Handling {
};
}
/**
* What the surface under the wheels does to the car.
*
* Roads have to be worth using. Without this the tarmac is decorative you can
* cut any corner across country at full speed, which makes both the route
* corridor and the whole road hierarchy pointless.
*/
export interface Surface {
/** Share of engine force that reaches the road. */
drive: number;
/** Extra braking from rough ground, as a share of the coast brake. */
drag: number;
/** Grip multiplier. */
grip: number;
}
export const TARMAC: Surface = { drive: 1, drag: 0, grip: 1 };
/**
* Rubble, verges and open country: slower, draggier, looser.
*
* Tuned so off-road tops out around a third of road speed. It has to *cost*
* something without being impossible going around a checkpoint cross-country
* is a legitimate move, and the first numbers here made it a 12 km/h crawl,
* which is not a choice, it is a wall.
*/
export const ROUGH: Surface = { drive: 0.55, drag: 0.45, grip: 0.8 };
export const surfaceFor = (onRoute: boolean): Surface => (onRoute ? TARMAC : ROUGH);
export interface WearInput {
/** Seconds of simulated time. */
dt: number;

View File

@ -8,10 +8,20 @@ import {
projectOntoSegment,
catchmentOf,
} from './roads';
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
import {
createAreas,
createHeat,
effectiveHeat,
levelFor,
propsFor,
speedAttention,
stepHeat,
type HeatState,
} from './heat';
import { generateWorld } from './world';
const roads = generateRoads(99, 220);
const areas = createAreas(roads, 220);
describe('road network', () => {
it('is reproducible and connected', () => {
@ -73,8 +83,8 @@ describe('route catchment', () => {
});
it('accrues heat at the same rate on the verge as on the tarmac', () => {
const onRoad = createHeat(roads);
const onVerge = createHeat(roads);
const onRoad = createHeat(roads, areas);
const onVerge = createHeat(roads, areas);
const centre = pointOnSegment(s, 0.5, 0);
const verge = beside(s.width / 2 + 2);
for (let m = 0; m < 200; m++) {
@ -82,12 +92,12 @@ describe('route catchment', () => {
dt: 1 / 60,
segmentId: routeAt(roads, centre.x, centre.z)!.id,
distance: 1,
});
areaId: null,}, areas);
stepHeat(onVerge, {
dt: 1 / 60,
segmentId: routeAt(roads, verge.x, verge.z)!.id,
distance: 1,
});
areaId: null,}, areas);
}
// No discount for hugging the shoulder — that is the whole point.
expect(onVerge.value[s.id]).toBeCloseTo(onRoad.value[s.id]!, 10);
@ -110,17 +120,17 @@ function simulate(state: HeatState, segmentId: number, metres: number, idleSecon
const changed: number[] = [];
const stepMetres = 1;
for (let m = 0; m < metres; m += stepMetres) {
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId, distance: stepMetres }));
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId, distance: stepMetres , areaId: null}, areas));
}
for (let i = 0; i < idleSeconds * 60; i++) {
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId: null, distance: 0 }));
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId: null, areaId: null, distance: 0 }, areas));
}
return changed;
}
describe('heat', () => {
it('escalates through every level as a road is reused', () => {
const state = createHeat(roads);
const state = createHeat(roads, areas);
const seen: string[] = [];
for (let trip = 0; trip < 6; trip++) {
simulate(state, 0, 80);
@ -132,14 +142,14 @@ describe('heat', () => {
});
it('leaves unused roads alone', () => {
const state = createHeat(roads);
const state = createHeat(roads, areas);
simulate(state, 0, 300);
expect(state.value[0]).toBeGreaterThan(0.5);
expect(state.value[1]).toBe(0);
});
it('cools a road that goes unused', () => {
const state = createHeat(roads);
const state = createHeat(roads, areas);
simulate(state, 0, 300);
const hot = state.value[0]!;
simulate(state, 1, 0, 120);
@ -147,14 +157,14 @@ describe('heat', () => {
});
it('does not flap between levels while hovering on a threshold', () => {
const state = createHeat(roads);
const state = createHeat(roads, areas);
// Park heat just above the patrol threshold, then hold it there.
simulate(state, 0, 90);
expect(state.level[0]).toBe('patrol');
let flips = 0;
for (let i = 0; i < 600; i++) {
// Roughly cancel the decay, so heat sits on the boundary.
const changed = stepHeat(state, { dt: 1 / 60, segmentId: 0, distance: 0.022 });
const changed = stepHeat(state, { dt: 1 / 60, segmentId: 0, areaId: null, distance: 0.022 }, areas);
flips += changed.length;
}
expect(flips).toBe(0);
@ -216,3 +226,97 @@ describe('heat props', () => {
}
});
});
describe('area heat', () => {
const wide = generateWorld(1337);
const wideAreas = createAreas(wide.roads, wide.extent);
/** Sits in one district for a while without using any particular road. */
const loiter = (state: HeatState, areaId: number, metres: number) => {
for (let m = 0; m < metres; m++) {
stepHeat(state, { dt: 1 / 60, segmentId: null, areaId, distance: 1 }, wideAreas);
}
};
it('accrues from being somewhere, not from using a road', () => {
const state = createHeat(wide.roads, wideAreas);
const areaId = wideAreas.ofSegment[0]![0]!;
loiter(state, areaId, 1500);
// That road was never driven; the district still noticed you.
expect(state.value[0]).toBe(0);
expect(state.area[areaId]).toBeGreaterThan(0.5);
});
it('bleeds into roads through the district that were never driven', () => {
const state = createHeat(wide.roads, wideAreas);
const areaId = wideAreas.ofSegment[0]![0]!;
loiter(state, areaId, 2000);
// Closes the loophole: working one district over while taking a different
// street every time used to cost nothing at all.
expect(effectiveHeat(state, wideAreas, 0)).toBeGreaterThan(0.25);
expect(state.level[0]).not.toBe('clear');
});
it('leaves roads in other districts alone', () => {
const state = createHeat(wide.roads, wideAreas);
const here = wideAreas.ofSegment[0]![0]!;
loiter(state, here, 2000);
const elsewhere = wide.roads.segments.find(
(s) => !wideAreas.ofSegment[s.id]!.includes(here),
)!;
expect(effectiveHeat(state, wideAreas, elsewhere.id)).toBe(0);
});
it('cannot fortify a road on its own', () => {
const state = createHeat(wide.roads, wideAreas);
const areaId = wideAreas.ofSegment[0]![0]!;
loiter(state, areaId, 6000);
expect(state.area[areaId]).toBe(1);
// A hot district gets roads patrolled. Concrete still takes real use.
expect(effectiveHeat(state, wideAreas, 0)).toBeLessThan(0.55);
});
it('outlasts road heat once you have gone', () => {
const state = createHeat(wide.roads, wideAreas);
const areaId = wideAreas.ofSegment[0]![0]!;
loiter(state, areaId, 1200);
state.value[0] = state.area[areaId]!;
for (let i = 0; i < 60 * 200; i++) {
stepHeat(state, { dt: 1 / 60, segmentId: null, areaId: null, distance: 0 }, wideAreas);
}
// A road is re-secured by one patrol; a district's reputation lingers.
expect(state.area[areaId]).toBeGreaterThan(state.value[0]!);
});
it('is not earned behind your own lines either', () => {
const state = createHeat(wide.roads, wideAreas);
const areaId = wideAreas.ofSegment[0]![0]!;
for (let m = 0; m < 3000; m++) {
stepHeat(
state,
{ dt: 1 / 60, segmentId: null, areaId, distance: 1, accrual: 0 },
wideAreas,
);
}
expect(state.area[areaId]).toBe(0);
});
});
describe('speed draws attention', () => {
it('counts a fast pass for more than a slow one', () => {
expect(speedAttention(30)).toBeGreaterThan(speedAttention(5));
});
it('still counts crawling past for something', () => {
expect(speedAttention(0)).toBeGreaterThan(0);
});
it('stops rewarding ever-higher speed past a point', () => {
// Otherwise the only strategy is to hold one exact number on the clock.
expect(speedAttention(200)).toBe(speedAttention(100));
});
});

View File

@ -27,25 +27,119 @@ const HYSTERESIS = 0.08;
/** Metres of driving that add a full point of heat. ~4 traversals to turret. */
const METRES_PER_HEAT = 330;
/**
* How much being quick draws attention, on top of being present.
*
* A car at walking pace is traffic. A car doing ninety through a checkpointed
* district is the thing people phone in. This is what makes "take the lanes
* slowly" a real alternative to "take the trunk fast" rather than a pure loss.
*/
const SPEED_ATTENTION_REFERENCE = 22;
export const speedAttention = (speed: number): number =>
0.55 + 0.45 * Math.min(2, Math.abs(speed) / SPEED_ATTENTION_REFERENCE);
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
const DECAY_PER_SECOND = 0.004;
/** Half the width of the opening left in a barricade. The car is 1.8m wide. */
const GAP_HALF_WIDTH = 2.1;
export interface HeatState {
/** Indexed by segment id. */
value: number[];
level: HeatLevel[];
// --- Areas ----------------------------------------------------------------
/**
* Districts, for heat that is about a *place* rather than a road.
*
* Road heat alone rewards a strange kind of play: work one district hard, but
* take a different street each time, and nothing ever gets hot. In reality the
* problem is that you keep turning up in this part of town at all. Area heat is
* that it accumulates wherever you are, on-road or off, and then bleeds into
* every road running through the district, including ones you have never used.
*/
const AREA_SIZE = 110;
/** Metres of driving inside a district that add a full point of its heat. */
const METRES_PER_AREA_HEAT = 1500;
/** Districts cool slower than roads: a reputation outlasts a patrol. */
const AREA_DECAY_PER_SECOND = 0.0022;
/**
* How much a fully hot district contributes to the roads through it. Below the
* barricade threshold on purpose a hot district alone gets a road patrolled,
* but fortifying it still takes somebody actually using that road.
*/
const AREA_INFLUENCE = 0.45;
export interface HeatAreas {
/** Cells per side. */
cells: number;
/** World coordinate of the grid's lower corner. */
origin: number;
/** Area cell ids each segment passes through. */
ofSegment: number[][];
}
export function createHeat(roads: RoadNetwork): HeatState {
export function createAreas(roads: RoadNetwork, extent: number): HeatAreas {
const origin = -(extent + 60);
const cells = Math.ceil((-origin * 2) / AREA_SIZE);
const index = (x: number, z: number): number | null => {
const col = Math.floor((x - origin) / AREA_SIZE);
const row = Math.floor((z - origin) / AREA_SIZE);
if (col < 0 || row < 0 || col >= cells || row >= cells) return null;
return row * cells + col;
};
// Walk each road and note every district it crosses, so a hot district
// reaches the roads through it however they happen to be laid out.
const ofSegment = roads.segments.map((s) => {
const found = new Set<number>();
const steps = Math.max(2, Math.ceil(s.length / 20));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const cell = index(s.ax + (s.bx - s.ax) * t, s.az + (s.bz - s.az) * t);
if (cell !== null) found.add(cell);
}
return [...found];
});
return { cells, origin, ofSegment };
}
export function areaAt(areas: HeatAreas, x: number, z: number): number | null {
const col = Math.floor((x - areas.origin) / AREA_SIZE);
const row = Math.floor((z - areas.origin) / AREA_SIZE);
if (col < 0 || row < 0 || col >= areas.cells || row >= areas.cells) return null;
return row * areas.cells + col;
}
export interface HeatState {
/** Indexed by segment id: heat earned by using that road. */
value: number[];
level: HeatLevel[];
/** Indexed by area cell: heat earned by being in that district at all. */
area: number[];
}
export function createHeat(roads: RoadNetwork, areas: HeatAreas): HeatState {
return {
value: new Array(roads.segments.length).fill(0),
level: new Array(roads.segments.length).fill('clear'),
area: new Array(areas.cells * areas.cells).fill(0),
};
}
/** The worst district a road runs through, as a contribution to its heat. */
export function areaInfluence(state: HeatState, areas: HeatAreas, segmentId: number): number {
let worst = 0;
for (const cell of areas.ofSegment[segmentId] ?? []) {
worst = Math.max(worst, state.area[cell] ?? 0);
}
return worst * AREA_INFLUENCE;
}
/**
* What the enemy actually reacts to: this road's own history plus the standing
* of the district it runs through.
*/
export const effectiveHeat = (state: HeatState, areas: HeatAreas, segmentId: number): number =>
Math.min(1, state.value[segmentId]! + areaInfluence(state, areas, segmentId));
export function levelFor(heat: number, current: HeatLevel): HeatLevel {
const rank = HEAT_LEVELS.indexOf(current);
let next: HeatLevel = 'clear';
@ -62,6 +156,8 @@ export interface HeatStep {
dt: number;
/** Segment the car is on right now, or null if off-road. */
segmentId: number | null;
/** District the car is in, on-road or not. */
areaId: number | null;
/** Metres travelled this step. */
distance: number;
/**
@ -74,17 +170,27 @@ export interface HeatStep {
}
/** Advances heat and returns the segments whose *level* changed. */
export function stepHeat(state: HeatState, step: HeatStep): number[] {
export function stepHeat(state: HeatState, step: HeatStep, areas: HeatAreas): number[] {
const changed: number[] = [];
const accrual = step.accrual ?? 1;
// Districts first: roads read from them, so they have to be current.
const areaDecay = AREA_DECAY_PER_SECOND * step.dt;
for (let cell = 0; cell < state.area.length; cell++) {
let value = state.area[cell]! - areaDecay;
// Being here at all counts, whether or not you are on a road.
if (cell === step.areaId) value += (step.distance / METRES_PER_AREA_HEAT) * accrual;
state.area[cell] = Math.min(1, Math.max(0, value));
}
for (let id = 0; id < state.value.length; id++) {
let heat = state.value[id]! - DECAY_PER_SECOND * step.dt * (step.decayFor?.(id) ?? 1);
if (id === step.segmentId) heat += (step.distance / METRES_PER_HEAT) * accrual;
heat = Math.min(1, Math.max(0, heat));
state.value[id] = heat;
state.value[id] = Math.min(1, Math.max(0, heat));
const next = levelFor(heat, state.level[id]!);
// The level reacts to the road *and* the district around it, so working one
// area over on a different street each time still gets it noticed.
const next = levelFor(effectiveHeat(state, areas, id), state.level[id]!);
if (next !== state.level[id]) {
state.level[id] = next;
changed.push(id);

View File

@ -3,7 +3,7 @@ import { generateWorld } from './world';
import { placeBases, baseAt } from './bases';
import { buildGraph, findRoute } from './routing';
import { createIntel, observe, summariseRoute, survey, hasSeen } from './intel';
import { createHeat, stepHeat } from './heat';
import { createAreas, createHeat, stepHeat } from './heat';
import {
accept,
createQuests,
@ -17,6 +17,7 @@ import {
const world = generateWorld(1337);
const graph = buildGraph(world.roads);
const bases = placeBases(world.roads, world.spawn);
const areas = createAreas(world.roads, world.extent);
describe('bases', () => {
it('puts one where the player starts, so the loop is available at once', () => {
@ -80,7 +81,7 @@ describe('routing', () => {
describe('intel', () => {
it('starts blank and only records what was observed', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
expect(hasSeen(intel, 4)).toBe(false);
observe(intel, heat, 4, 10);
expect(hasSeen(intel, 4)).toBe(true);
@ -89,11 +90,11 @@ describe('intel', () => {
it('remembers what a road was, not what it has since become', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
observe(intel, heat, 0, 0);
expect(intel.rememberedLevel[0]).toBe('clear');
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 });
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 , areaId: null}, areas);
expect(heat.level[0]).toBe('turret');
// The player was not there to see it escalate.
expect(intel.rememberedLevel[0]).toBe('clear');
@ -101,7 +102,7 @@ describe('intel', () => {
it('surveys a whole area at once, which is what recon is for', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
const node = world.roads.nodes[24]!;
const mapped = survey(intel, heat, world.roads, node.x, node.z, 5);
expect(mapped).toBeGreaterThan(1);
@ -110,13 +111,13 @@ describe('intel', () => {
it('summarises a route by its worst road and how much is unscouted', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
expect(summariseRoute(intel, route.segments, 0).unknownCount).toBe(route.segments.length);
for (let m = 0; m < 400; m++) {
stepHeat(heat, { dt: 1 / 60, segmentId: route.segments[0]!, distance: 1 });
stepHeat(heat, { dt: 1 / 60, segmentId: route.segments[0]!, distance: 1 , areaId: null}, areas);
}
for (const id of route.segments) observe(intel, heat, id, 100);

View File

@ -13,11 +13,12 @@ import {
depthAt,
stepFront,
} from './regions';
import { createHeat, stepHeat } from './heat';
import { createHeat, stepHeat, createAreas } from './heat';
import { cellIndex, createIntel, isExplored, reveal } from './intel';
const world = generateWorld(1337);
const front = createFront(1337, world.extent, world.spawn);
const areas = createAreas(world.roads, world.extent);
describe('front line', () => {
it('starts the player behind their own line', () => {
@ -60,14 +61,14 @@ describe('front line', () => {
describe('heat under regional control', () => {
it('accrues nothing at all behind your own lines', () => {
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
for (let m = 0; m < 2000; m++) {
stepHeat(heat, {
dt: 1 / 60,
segmentId: 0,
distance: 1,
accrual: HEAT_MULTIPLIER.liberated,
});
areaId: null,}, areas);
}
// Not "a little" — exactly none. Your own back garden never fortifies.
expect(heat.value[0]).toBe(0);
@ -76,9 +77,9 @@ describe('heat under regional control', () => {
it('accrues fastest past the front', () => {
const drive = (accrual: number) => {
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
for (let m = 0; m < 200; m++) {
stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1, accrual });
stepHeat(heat, { dt: 1 / 60, segmentId: 0, areaId: null, distance: 1, accrual }, areas);
}
return heat.value[0]!;
};
@ -88,10 +89,10 @@ describe('heat under regional control', () => {
it('lets your own side dismantle a checkpoint faster than the enemy rebuilds', () => {
const cool = (decay: number) => {
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
heat.value[0] = 1;
for (let i = 0; i < 60 * 60; i++) {
stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0, decayFor: () => decay });
stepHeat(heat, { dt: 1 / 60, segmentId: null, areaId: null, distance: 0, decayFor: () => decay }, areas);
}
return heat.value[0]!;
};
@ -99,7 +100,7 @@ describe('heat under regional control', () => {
});
it('decays a road by who holds that road, not who holds the car', () => {
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
heat.value[0] = 1;
heat.value[1] = 1;
for (let i = 0; i < 60 * 30; i++) {
@ -108,7 +109,7 @@ describe('heat under regional control', () => {
segmentId: null,
distance: 0,
decayFor: (id) => (id === 0 ? DECAY_MULTIPLIER.liberated : DECAY_MULTIPLIER.frontier),
});
areaId: null,}, areas);
}
expect(heat.value[0]).toBeLessThan(heat.value[1]!);
});

View File

@ -1,23 +1,24 @@
import { describe, expect, it } from 'vitest';
import { generateWorld } from './world';
import { applyWear, freshCondition } from './car';
import { createHeat, stepHeat } from './heat';
import { createHeat, stepHeat, createAreas } from './heat';
import { createIntel, observe, reveal } from './intel';
import { applyMissionImpact, controlAt, createFront, stepFront } from './regions';
import { createQuests } from './quests';
import { apply, isLoadable, serialise, SAVE_VERSION, type Snapshot } from './save';
const world = generateWorld(1337);
const areas = createAreas(world.roads, world.extent);
/** A campaign with some history behind it, so a round trip has work to do. */
function played(): Snapshot {
const heat = createHeat(world.roads);
const heat = createHeat(world.roads, areas);
const intel = createIntel(world.roads, world.extent);
const front = createFront(1337, world.extent, world.spawn);
const quests = createQuests();
let condition = freshCondition();
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 2, distance: 1 });
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 2, areaId: null, distance: 1 }, areas);
for (let i = 0; i < 200; i++) {
condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 3e4 });
}
@ -50,7 +51,7 @@ function blank(): Snapshot {
elapsed: 0,
condition: freshCondition(),
car: { position: [0, 0, 0], rotation: [0, 0, 0, 1], linvel: [0, 0, 0], angvel: [0, 0, 0] },
heat: createHeat(world.roads),
heat: createHeat(world.roads, areas),
intel: createIntel(world.roads, world.extent),
front,
quests: createQuests(),
@ -62,7 +63,7 @@ describe('save round trip', () => {
const source = played();
const restored = blank();
const data = JSON.parse(JSON.stringify(serialise(source)));
expect(isLoadable(data, 1337, world.roads.segments.length, restored.intel.explored.length)).toBe(
expect(isLoadable(data, 1337, world.roads.segments.length, restored.intel.explored.length, areas.cells ** 2)).toBe(
true,
);
apply(data, restored);
@ -135,32 +136,35 @@ describe('save round trip', () => {
describe('save validation', () => {
const segments = world.roads.segments.length;
const cells = createIntel(world.roads, world.extent).explored.length;
const areaCells = areas.cells ** 2;
const good = () => JSON.parse(JSON.stringify(serialise(played())));
it('accepts a save for this world', () => {
expect(isLoadable(good(), 1337, segments, cells)).toBe(true);
expect(isLoadable(good(), 1337, segments, cells, areaCells)).toBe(true);
});
it('rejects a save from a different world', () => {
// Seeds define the map; loading one campaign's roads into another's world
// would put checkpoints and memories on roads that do not exist.
expect(isLoadable(good(), 999, segments, cells)).toBe(false);
expect(isLoadable(good(), 999, segments, cells, areaCells)).toBe(false);
});
it('rejects a save from an older format', () => {
expect(isLoadable({ ...good(), version: SAVE_VERSION - 1 }, 1337, segments, cells)).toBe(false);
expect(
isLoadable({ ...good(), version: SAVE_VERSION - 1 }, 1337, segments, cells, areaCells),
).toBe(false);
});
it('rejects a save whose arrays no longer match the generator', () => {
// Changing world generation invalidates old saves. Refusing is correct:
// a half-restored campaign is worse than a fresh one.
expect(isLoadable(good(), 1337, segments + 1, cells)).toBe(false);
expect(isLoadable(good(), 1337, segments, cells + 1)).toBe(false);
expect(isLoadable(good(), 1337, segments + 1, cells, areaCells)).toBe(false);
expect(isLoadable(good(), 1337, segments, cells + 1, areaCells)).toBe(false);
});
it('rejects junk without throwing', () => {
for (const junk of [null, undefined, 42, 'save', {}, { version: SAVE_VERSION }]) {
expect(isLoadable(junk, 1337, segments, cells)).toBe(false);
expect(isLoadable(junk, 1337, segments, cells, areaCells)).toBe(false);
}
});
});

View File

@ -16,7 +16,7 @@ import type { Intel } from './intel';
import type { Boundaries, Front } from './regions';
import type { ActiveQuest, QuestState } from './quests';
export const SAVE_VERSION = 1;
export const SAVE_VERSION = 2;
export interface CarSnapshot {
position: [number, number, number];
@ -33,7 +33,7 @@ export interface SaveData {
elapsed: number;
condition: CarCondition;
car: CarSnapshot;
heat: { value: number[]; level: HeatLevel[] };
heat: { value: number[]; level: HeatLevel[]; area: number[] };
intel: {
rememberedHeat: number[];
rememberedLevel: HeatLevel[];
@ -70,7 +70,7 @@ export function serialise(s: Snapshot): SaveData {
elapsed: s.elapsed,
condition: { level: { ...s.condition.level }, ceiling: { ...s.condition.ceiling } },
car: s.car,
heat: { value: [...s.heat.value], level: [...s.heat.level] },
heat: { value: [...s.heat.value], level: [...s.heat.level], area: [...s.heat.area] },
intel: {
rememberedHeat: [...s.intel.rememberedHeat],
rememberedLevel: [...s.intel.rememberedLevel],
@ -94,7 +94,13 @@ export function serialise(s: Snapshot): SaveData {
* Checks a save is for this world and this format before anything is applied.
* Half-restoring a campaign is worse than starting a fresh one.
*/
export function isLoadable(data: unknown, seed: number, segments: number, cells: number): boolean {
export function isLoadable(
data: unknown,
seed: number,
segments: number,
cells: number,
areas: number,
): boolean {
if (!data || typeof data !== 'object') return false;
const save = data as Partial<SaveData>;
if (save.version !== SAVE_VERSION || save.seed !== seed) return false;
@ -103,6 +109,7 @@ export function isLoadable(data: unknown, seed: number, segments: number, cells:
// it. A generator change invalidates old saves, which is the correct outcome.
return (
save.heat.value?.length === segments &&
save.heat.area?.length === areas &&
save.intel.seenAt?.length === segments &&
save.intel.explored?.length === cells
);
@ -117,6 +124,7 @@ export function apply(data: SaveData, into: Snapshot): void {
into.heat.value = [...data.heat.value];
into.heat.level = [...data.heat.level];
into.heat.area = [...data.heat.area];
into.intel.rememberedHeat = [...data.intel.rememberedHeat];
into.intel.rememberedLevel = [...data.intel.rememberedLevel];

View File

@ -3,7 +3,7 @@ import { makeRng } from '../core/rng';
import { generateWorld } from './world';
import { buildGraph } from './routing';
import { createFront, controlAt } from './regions';
import { createHeat } from './heat';
import { createHeat, createAreas } from './heat';
import { freshCondition } from './car';
import {
AMBIENT_PATROLS,
@ -12,7 +12,9 @@ import {
hasBuilt,
stepUnits,
PATROL_HEAT_DECAY,
PEDESTRIAN_TARGET,
patrolProgress,
TRAFFIC_TARGET,
type UnitState,
} from './units';
import { createCombat, segmentHits, stepCombat } from './combat';
@ -20,6 +22,7 @@ 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,
@ -66,8 +69,14 @@ describe('a world with people in it', () => {
it('does not let the population grow without bound', () => {
const state = createUnits();
run(state, 400);
expect(state.units.filter((u) => u.role === 'traffic').length).toBeLessThanOrEqual(14);
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeLessThanOrEqual(10);
// 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', () => {
@ -426,7 +435,7 @@ describe('rounds in flight', () => {
describe('heat and units together', () => {
it('leaves heat alone when no patrol has arrived', () => {
const heat = createHeat(world.roads);
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));

View File

@ -103,10 +103,10 @@ export const createUnits = (): UnitState => ({
// --- Tuning ---------------------------------------------------------------
/** How many civilian vehicles try to exist near the player. */
const TRAFFIC_TARGET = 14;
const PEDESTRIAN_TARGET = 10;
export const TRAFFIC_TARGET = 34;
export const PEDESTRIAN_TARGET = 30;
/** Units further than this from the player are despawned; the world is big. */
export const SIM_RADIUS = 420;
export const SIM_RADIUS = 640;
/** Civilians keep clear of the shooting. */
const CIVILIAN_FLEE_RANGE = 70;
@ -114,7 +114,7 @@ const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
/** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */
export const PATROL_HEAT_DECAY = 0.012;
export const PATROL_HEAT_DECAY = 0.005;
/**
* Seconds of an engineer standing on site per stage. Concrete is quicker than a
* tower, but neither is instant arriving mid-build is meant to be a thing that

View File

@ -24,6 +24,8 @@ export interface HudModel {
/** False while on the verge — still counts as using the road. */
onTarmac: boolean;
value: number;
/** Standing of the district, which feeds every road through it. */
area: number;
level: HeatLevel | null;
};
quest: {
@ -113,6 +115,7 @@ export function createHud(seed: number) {
: `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}`
}`,
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
`[debug] area ${bar(model.heat.area)}`,
'',
`seed ${seed}`,
`WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`,