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` `Clear → Patrol → Barricade → Turret`
- **Patrol** parks a vehicle on the verge. The road narrows. - **Patrol** sends a car to work the road for a while, which cools it back down.
- **Barricade** puts concrete across it, leaving a gap you have to slow for. - **Barricade** sends an engineer to pour concrete across it, leaving a gap you
- **Turret** adds a tower overlooking the checkpoint. 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 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 cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
guesses meant to be tuned. 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 ### The road hierarchy
Roads are not all the same road: 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 the corridor too generous and heat becomes unavoidable everywhere, and "take a
different route" stops being a choice at all. 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 ### Buildings, and why they are dense
Off-road used to be open country, which made every barricade optional — you just 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 ### Other deliberate choices
- **No AI yet.** The props are stationary hazards. Escalation currently means the - **Concrete never closes around the car standing in the gap.** A block spawning
road gets slower and more expensive to get wrong, which is enough to test the on top of you would put a static collider inside the chassis, so the last slab
phase's real question. waits until you move.
- **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.
- **A checkpoint's position is seeded from its segment**, so a given stretch of - **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 road always fortifies in the same place. Recognising it is part of learning
the map. the map.

View File

@ -1,7 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { generateWorld } from './sim/world'; import { generateWorld } from './sim/world';
import { applyWear, deriveHandling, freshCondition, repair } from './sim/car'; import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car';
import { createHeat, stepHeat } from './sim/heat'; import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads'; import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
import { baseAt, placeBases } from './sim/bases'; import { baseAt, placeBases } from './sim/bases';
import { buildGraph } from './sim/routing'; import { buildGraph } from './sim/routing';
@ -93,7 +93,8 @@ async function boot() {
const hud = createHud(seed); const hud = createHud(seed);
const board = createBoard(); const board = createBoard();
const driveState = createDriveState(); 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 heatProps = createHeatProps(model.roads, physics, view.scene);
const intel = createIntel(model.roads, model.extent); const intel = createIntel(model.roads, model.extent);
const quests = createQuests(); const quests = createQuests();
@ -184,7 +185,12 @@ async function boot() {
}; };
// --- Persistence --- // --- 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 snapshot = (): Snapshot => {
const t = physics.chassis.translation(); const t = physics.chassis.translation();
@ -287,7 +293,9 @@ async function boot() {
if (cmd.respawn && !respawnLatch) physics.respawn(); if (cmd.respawn && !respawnLatch) physics.respawn();
respawnLatch = cmd.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); physics.step(dt);
captureChassis(); captureChassis();
@ -320,19 +328,21 @@ async function boot() {
const heatChanged = stepHeat(heat, { const heatChanged = stepHeat(heat, {
dt, dt,
segmentId: currentSegment, segmentId: currentSegment,
areaId: areaAt(areas, at.x, at.z),
distance, distance,
// Who holds the ground, times how conspicuous this class of road is. // 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 // A farm track behind the lines costs nothing; a trunk past the front
// is the most watched thing you can drive on. // is the most watched thing you can drive on.
accrual: accrual:
HEAT_MULTIPLIER[control] * HEAT_MULTIPLIER[control] *
speedAttention(speed) *
(currentSegment === null (currentSegment === null
? 1 ? 1
: ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]), : ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]),
// Each road decays according to whoever holds *it*, not whoever holds // Each road decays according to whoever holds *it*, not whoever holds
// the ground the car happens to be standing on. // the ground the car happens to be standing on.
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!], decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
}); }, areas);
heatProps.sync({ x: at.x, z: at.z }); heatProps.sync({ x: at.x, z: at.z });
// Escalation is entirely something the enemy has to *do*. Every level // Escalation is entirely something the enemy has to *do*. Every level
@ -663,7 +673,8 @@ async function boot() {
heat: { heat: {
segmentId: currentSegment, segmentId: currentSegment,
onTarmac, 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]!, level: currentSegment === null ? null : heat.level[currentSegment]!,
}, },
quest: quest:
@ -699,7 +710,7 @@ async function boot() {
view, view,
physics, physics,
// Live state, so a script can find something interesting and go look at it. // 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) { teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true); physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, 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}`; 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 lastSave = 0;
let failed = false; let failed = false;
@ -39,7 +39,7 @@ export function createPersistence(seed: number, segments: number, cells: number)
const raw = localStorage.getItem(keyFor(seed)); const raw = localStorage.getItem(keyFor(seed));
if (!raw) return null; if (!raw) return null;
const data: unknown = JSON.parse(raw); 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 // A save from an older format or an older world generator. Dropping
// it is correct — restoring half of it would be worse. // it is correct — restoring half of it would be worse.
localStorage.removeItem(keyFor(seed)); localStorage.removeItem(keyFor(seed));

View File

@ -1,5 +1,5 @@
import type { DriverInput } from '../core/input'; 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 type { PhysicsWorld } from './physics';
import { WHEELS } from '../carSpec'; import { WHEELS } from '../carSpec';
@ -36,6 +36,7 @@ export function drive(
input: DriverInput, input: DriverInput,
handling: Handling, handling: Handling,
dt: number, dt: number,
surface: Surface = TARMAC,
): void { ): void {
const { vehicle } = physics; const { vehicle } = physics;
const speed = vehicle.currentVehicleSpeed(); const speed = vehicle.currentVehicleSpeed();
@ -54,13 +55,16 @@ export function drive(
// Throttle vs. brake: pressing back while rolling forward is braking, not reverse. // Throttle vs. brake: pressing back while rolling forward is braking, not reverse.
const wantsReverse = input.throttle < 0; const wantsReverse = input.throttle < 0;
const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1); 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; let brakeForce: number;
if (input.handbrake) brakeForce = handling.brakeForce * 1.6; if (input.handbrake) brakeForce = handling.brakeForce * 1.6;
else if (braking) brakeForce = handling.brakeForce; else if (braking) brakeForce = handling.brakeForce;
else if (input.throttle === 0) brakeForce = handling.coastBrake; else if (input.throttle === 0) brakeForce = handling.coastBrake;
else brakeForce = 0; 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. // 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. // 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); vehicle.setWheelEngineForce(i, w.driven ? engineForce : 0);
// Handbrake locks the rear only — that is where the rotation comes from. // Handbrake locks the rear only — that is where the rotation comes from.
vehicle.setWheelBrake(i, input.handbrake && w.steered ? 0 : brakeForce); vehicle.setWheelBrake(i, input.handbrake && w.steered ? 0 : brakeForce);
vehicle.setWheelFrictionSlip(i, handling.frictionSlip); vehicle.setWheelFrictionSlip(i, handling.frictionSlip * surface.grip);
vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness); vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness * surface.grip);
} }
} }

View File

@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createPhysics } from './physics'; import { createPhysics } from './physics';
import { createDriveState, drive } from './drive'; 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 type { DriverInput } from '../core/input';
import { generateWorld } from '../sim/world'; 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', () => { describe('vehicle', () => {
it('settles on its suspension instead of sinking or bouncing away', async () => { it('settles on its suspension instead of sinking or bouncing away', async () => {
const r = await run({}, 2); 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 { export interface WearInput {
/** Seconds of simulated time. */ /** Seconds of simulated time. */
dt: number; dt: number;

View File

@ -8,10 +8,20 @@ import {
projectOntoSegment, projectOntoSegment,
catchmentOf, catchmentOf,
} from './roads'; } 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'; import { generateWorld } from './world';
const roads = generateRoads(99, 220); const roads = generateRoads(99, 220);
const areas = createAreas(roads, 220);
describe('road network', () => { describe('road network', () => {
it('is reproducible and connected', () => { 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', () => { it('accrues heat at the same rate on the verge as on the tarmac', () => {
const onRoad = createHeat(roads); const onRoad = createHeat(roads, areas);
const onVerge = createHeat(roads); const onVerge = createHeat(roads, areas);
const centre = pointOnSegment(s, 0.5, 0); const centre = pointOnSegment(s, 0.5, 0);
const verge = beside(s.width / 2 + 2); const verge = beside(s.width / 2 + 2);
for (let m = 0; m < 200; m++) { for (let m = 0; m < 200; m++) {
@ -82,12 +92,12 @@ describe('route catchment', () => {
dt: 1 / 60, dt: 1 / 60,
segmentId: routeAt(roads, centre.x, centre.z)!.id, segmentId: routeAt(roads, centre.x, centre.z)!.id,
distance: 1, distance: 1,
}); areaId: null,}, areas);
stepHeat(onVerge, { stepHeat(onVerge, {
dt: 1 / 60, dt: 1 / 60,
segmentId: routeAt(roads, verge.x, verge.z)!.id, segmentId: routeAt(roads, verge.x, verge.z)!.id,
distance: 1, distance: 1,
}); areaId: null,}, areas);
} }
// No discount for hugging the shoulder — that is the whole point. // No discount for hugging the shoulder — that is the whole point.
expect(onVerge.value[s.id]).toBeCloseTo(onRoad.value[s.id]!, 10); 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 changed: number[] = [];
const stepMetres = 1; const stepMetres = 1;
for (let m = 0; m < metres; m += stepMetres) { 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++) { 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; return changed;
} }
describe('heat', () => { describe('heat', () => {
it('escalates through every level as a road is reused', () => { it('escalates through every level as a road is reused', () => {
const state = createHeat(roads); const state = createHeat(roads, areas);
const seen: string[] = []; const seen: string[] = [];
for (let trip = 0; trip < 6; trip++) { for (let trip = 0; trip < 6; trip++) {
simulate(state, 0, 80); simulate(state, 0, 80);
@ -132,14 +142,14 @@ describe('heat', () => {
}); });
it('leaves unused roads alone', () => { it('leaves unused roads alone', () => {
const state = createHeat(roads); const state = createHeat(roads, areas);
simulate(state, 0, 300); simulate(state, 0, 300);
expect(state.value[0]).toBeGreaterThan(0.5); expect(state.value[0]).toBeGreaterThan(0.5);
expect(state.value[1]).toBe(0); expect(state.value[1]).toBe(0);
}); });
it('cools a road that goes unused', () => { it('cools a road that goes unused', () => {
const state = createHeat(roads); const state = createHeat(roads, areas);
simulate(state, 0, 300); simulate(state, 0, 300);
const hot = state.value[0]!; const hot = state.value[0]!;
simulate(state, 1, 0, 120); simulate(state, 1, 0, 120);
@ -147,14 +157,14 @@ describe('heat', () => {
}); });
it('does not flap between levels while hovering on a threshold', () => { 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. // Park heat just above the patrol threshold, then hold it there.
simulate(state, 0, 90); simulate(state, 0, 90);
expect(state.level[0]).toBe('patrol'); expect(state.level[0]).toBe('patrol');
let flips = 0; let flips = 0;
for (let i = 0; i < 600; i++) { for (let i = 0; i < 600; i++) {
// Roughly cancel the decay, so heat sits on the boundary. // 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; flips += changed.length;
} }
expect(flips).toBe(0); 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. */ /** Metres of driving that add a full point of heat. ~4 traversals to turret. */
const METRES_PER_HEAT = 330; 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. */ /** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
const DECAY_PER_SECOND = 0.004; const DECAY_PER_SECOND = 0.004;
/** Half the width of the opening left in a barricade. The car is 1.8m wide. */ /** Half the width of the opening left in a barricade. The car is 1.8m wide. */
const GAP_HALF_WIDTH = 2.1; const GAP_HALF_WIDTH = 2.1;
export interface HeatState { // --- Areas ----------------------------------------------------------------
/** Indexed by segment id. */
value: number[]; /**
level: HeatLevel[]; * 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 { return {
value: new Array(roads.segments.length).fill(0), value: new Array(roads.segments.length).fill(0),
level: new Array(roads.segments.length).fill('clear'), 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 { export function levelFor(heat: number, current: HeatLevel): HeatLevel {
const rank = HEAT_LEVELS.indexOf(current); const rank = HEAT_LEVELS.indexOf(current);
let next: HeatLevel = 'clear'; let next: HeatLevel = 'clear';
@ -62,6 +156,8 @@ export interface HeatStep {
dt: number; dt: number;
/** Segment the car is on right now, or null if off-road. */ /** Segment the car is on right now, or null if off-road. */
segmentId: number | null; segmentId: number | null;
/** District the car is in, on-road or not. */
areaId: number | null;
/** Metres travelled this step. */ /** Metres travelled this step. */
distance: number; distance: number;
/** /**
@ -74,17 +170,27 @@ export interface HeatStep {
} }
/** Advances heat and returns the segments whose *level* changed. */ /** 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 changed: number[] = [];
const accrual = step.accrual ?? 1; 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++) { for (let id = 0; id < state.value.length; id++) {
let heat = state.value[id]! - DECAY_PER_SECOND * step.dt * (step.decayFor?.(id) ?? 1); 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; if (id === step.segmentId) heat += (step.distance / METRES_PER_HEAT) * accrual;
heat = Math.min(1, Math.max(0, heat)); state.value[id] = Math.min(1, Math.max(0, heat));
state.value[id] = 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]) { if (next !== state.level[id]) {
state.level[id] = next; state.level[id] = next;
changed.push(id); changed.push(id);

View File

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

View File

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

View File

@ -1,23 +1,24 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { generateWorld } from './world'; import { generateWorld } from './world';
import { applyWear, freshCondition } from './car'; import { applyWear, freshCondition } from './car';
import { createHeat, stepHeat } from './heat'; import { createHeat, stepHeat, createAreas } from './heat';
import { createIntel, observe, reveal } from './intel'; import { createIntel, observe, reveal } from './intel';
import { applyMissionImpact, controlAt, createFront, stepFront } from './regions'; import { applyMissionImpact, controlAt, createFront, stepFront } from './regions';
import { createQuests } from './quests'; import { createQuests } from './quests';
import { apply, isLoadable, serialise, SAVE_VERSION, type Snapshot } from './save'; import { apply, isLoadable, serialise, SAVE_VERSION, type Snapshot } from './save';
const world = generateWorld(1337); 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. */ /** A campaign with some history behind it, so a round trip has work to do. */
function played(): Snapshot { function played(): Snapshot {
const heat = createHeat(world.roads); const heat = createHeat(world.roads, areas);
const intel = createIntel(world.roads, world.extent); const intel = createIntel(world.roads, world.extent);
const front = createFront(1337, world.extent, world.spawn); const front = createFront(1337, world.extent, world.spawn);
const quests = createQuests(); const quests = createQuests();
let condition = freshCondition(); 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++) { for (let i = 0; i < 200; i++) {
condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 3e4 }); condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 3e4 });
} }
@ -50,7 +51,7 @@ function blank(): Snapshot {
elapsed: 0, elapsed: 0,
condition: freshCondition(), condition: freshCondition(),
car: { position: [0, 0, 0], rotation: [0, 0, 0, 1], linvel: [0, 0, 0], angvel: [0, 0, 0] }, 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), intel: createIntel(world.roads, world.extent),
front, front,
quests: createQuests(), quests: createQuests(),
@ -62,7 +63,7 @@ describe('save round trip', () => {
const source = played(); const source = played();
const restored = blank(); const restored = blank();
const data = JSON.parse(JSON.stringify(serialise(source))); 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, true,
); );
apply(data, restored); apply(data, restored);
@ -135,32 +136,35 @@ describe('save round trip', () => {
describe('save validation', () => { describe('save validation', () => {
const segments = world.roads.segments.length; const segments = world.roads.segments.length;
const cells = createIntel(world.roads, world.extent).explored.length; const cells = createIntel(world.roads, world.extent).explored.length;
const areaCells = areas.cells ** 2;
const good = () => JSON.parse(JSON.stringify(serialise(played()))); const good = () => JSON.parse(JSON.stringify(serialise(played())));
it('accepts a save for this world', () => { 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', () => { it('rejects a save from a different world', () => {
// Seeds define the map; loading one campaign's roads into another's 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. // 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', () => { 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', () => { it('rejects a save whose arrays no longer match the generator', () => {
// Changing world generation invalidates old saves. Refusing is correct: // Changing world generation invalidates old saves. Refusing is correct:
// a half-restored campaign is worse than a fresh one. // a half-restored campaign is worse than a fresh one.
expect(isLoadable(good(), 1337, segments + 1, cells)).toBe(false); expect(isLoadable(good(), 1337, segments + 1, cells, areaCells)).toBe(false);
expect(isLoadable(good(), 1337, segments, cells + 1)).toBe(false); expect(isLoadable(good(), 1337, segments, cells + 1, areaCells)).toBe(false);
}); });
it('rejects junk without throwing', () => { it('rejects junk without throwing', () => {
for (const junk of [null, undefined, 42, 'save', {}, { version: SAVE_VERSION }]) { 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 { Boundaries, Front } from './regions';
import type { ActiveQuest, QuestState } from './quests'; import type { ActiveQuest, QuestState } from './quests';
export const SAVE_VERSION = 1; export const SAVE_VERSION = 2;
export interface CarSnapshot { export interface CarSnapshot {
position: [number, number, number]; position: [number, number, number];
@ -33,7 +33,7 @@ export interface SaveData {
elapsed: number; elapsed: number;
condition: CarCondition; condition: CarCondition;
car: CarSnapshot; car: CarSnapshot;
heat: { value: number[]; level: HeatLevel[] }; heat: { value: number[]; level: HeatLevel[]; area: number[] };
intel: { intel: {
rememberedHeat: number[]; rememberedHeat: number[];
rememberedLevel: HeatLevel[]; rememberedLevel: HeatLevel[];
@ -70,7 +70,7 @@ export function serialise(s: Snapshot): SaveData {
elapsed: s.elapsed, elapsed: s.elapsed,
condition: { level: { ...s.condition.level }, ceiling: { ...s.condition.ceiling } }, condition: { level: { ...s.condition.level }, ceiling: { ...s.condition.ceiling } },
car: s.car, 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: { intel: {
rememberedHeat: [...s.intel.rememberedHeat], rememberedHeat: [...s.intel.rememberedHeat],
rememberedLevel: [...s.intel.rememberedLevel], 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. * 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. * 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; if (!data || typeof data !== 'object') return false;
const save = data as Partial<SaveData>; const save = data as Partial<SaveData>;
if (save.version !== SAVE_VERSION || save.seed !== seed) return false; 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. // it. A generator change invalidates old saves, which is the correct outcome.
return ( return (
save.heat.value?.length === segments && save.heat.value?.length === segments &&
save.heat.area?.length === areas &&
save.intel.seenAt?.length === segments && save.intel.seenAt?.length === segments &&
save.intel.explored?.length === cells 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.value = [...data.heat.value];
into.heat.level = [...data.heat.level]; into.heat.level = [...data.heat.level];
into.heat.area = [...data.heat.area];
into.intel.rememberedHeat = [...data.intel.rememberedHeat]; into.intel.rememberedHeat = [...data.intel.rememberedHeat];
into.intel.rememberedLevel = [...data.intel.rememberedLevel]; into.intel.rememberedLevel = [...data.intel.rememberedLevel];

View File

@ -3,7 +3,7 @@ import { makeRng } from '../core/rng';
import { generateWorld } from './world'; import { generateWorld } from './world';
import { buildGraph } from './routing'; import { buildGraph } from './routing';
import { createFront, controlAt } from './regions'; import { createFront, controlAt } from './regions';
import { createHeat } from './heat'; import { createHeat, createAreas } from './heat';
import { freshCondition } from './car'; import { freshCondition } from './car';
import { import {
AMBIENT_PATROLS, AMBIENT_PATROLS,
@ -12,7 +12,9 @@ import {
hasBuilt, hasBuilt,
stepUnits, stepUnits,
PATROL_HEAT_DECAY, PATROL_HEAT_DECAY,
PEDESTRIAN_TARGET,
patrolProgress, patrolProgress,
TRAFFIC_TARGET,
type UnitState, type UnitState,
} from './units'; } from './units';
import { createCombat, segmentHits, stepCombat } from './combat'; import { createCombat, segmentHits, stepCombat } from './combat';
@ -20,6 +22,7 @@ import { createCombat, segmentHits, stepCombat } from './combat';
const world = generateWorld(1337); const world = generateWorld(1337);
const graph = buildGraph(world.roads); const graph = buildGraph(world.roads);
const front = createFront(1337, world.extent, world.spawn); const front = createFront(1337, world.extent, world.spawn);
const areas = createAreas(world.roads, world.extent);
function run( function run(
state: UnitState, state: UnitState,
@ -66,8 +69,14 @@ describe('a world with people in it', () => {
it('does not let the population grow without bound', () => { it('does not let the population grow without bound', () => {
const state = createUnits(); const state = createUnits();
run(state, 400); run(state, 400);
expect(state.units.filter((u) => u.role === 'traffic').length).toBeLessThanOrEqual(14); // Asserted against the targets themselves rather than hardcoded numbers,
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeLessThanOrEqual(10); // 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', () => { it('cleans up anything that wanders too far from the player', () => {
@ -426,7 +435,7 @@ describe('rounds in flight', () => {
describe('heat and units together', () => { describe('heat and units together', () => {
it('leaves heat alone when no patrol has arrived', () => { 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; heat.value[4] = 0.5;
const state = createUnits(); const state = createUnits();
dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7)); dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7));

View File

@ -103,10 +103,10 @@ export const createUnits = (): UnitState => ({
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
/** How many civilian vehicles try to exist near the player. */ /** How many civilian vehicles try to exist near the player. */
const TRAFFIC_TARGET = 14; export const TRAFFIC_TARGET = 34;
const PEDESTRIAN_TARGET = 10; export const PEDESTRIAN_TARGET = 30;
/** Units further than this from the player are despawned; the world is big. */ /** 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. */ /** Civilians keep clear of the shooting. */
const CIVILIAN_FLEE_RANGE = 70; 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. */ /** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90; const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */ /** 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 * 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 * 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. */ /** False while on the verge — still counts as using the road. */
onTarmac: boolean; onTarmac: boolean;
value: number; value: number;
/** Standing of the district, which feeds every road through it. */
area: number;
level: HeatLevel | null; level: HeatLevel | null;
}; };
quest: { quest: {
@ -113,6 +115,7 @@ export function createHud(seed: number) {
: `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}` : `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}`
}`, }`,
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`, `[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
`[debug] area ${bar(model.heat.area)}`,
'', '',
`seed ${seed}`, `seed ${seed}`,
`WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`, `WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`,