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:
parent
9ae00dc978
commit
9eaa05d7ef
56
README.md
56
README.md
@ -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.
|
||||
|
||||
27
src/main.ts
27
src/main.ts
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
124
src/sim/heat.ts
124
src/sim/heat.ts
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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]!);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'}`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user