diff --git a/README.md b/README.md index b51758c..f57038f 100644 --- a/README.md +++ b/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. diff --git a/src/main.ts b/src/main.ts index 312037c..b0108a7 100644 --- a/src/main.ts +++ b/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); diff --git a/src/persistence.ts b/src/persistence.ts index f2a69e5..ffeb933 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -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)); diff --git a/src/physics/drive.ts b/src/physics/drive.ts index aec790e..372e0c2 100644 --- a/src/physics/drive.ts +++ b/src/physics/drive.ts @@ -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); } } diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index 349633f..e8e1cc5 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -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); diff --git a/src/sim/car.ts b/src/sim/car.ts index e80afd9..1ac9c21 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -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; diff --git a/src/sim/heat.test.ts b/src/sim/heat.test.ts index d79f8f5..58079bd 100644 --- a/src/sim/heat.test.ts +++ b/src/sim/heat.test.ts @@ -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)); + }); +}); diff --git a/src/sim/heat.ts b/src/sim/heat.ts index ad606c7..9cae0f7 100644 --- a/src/sim/heat.ts +++ b/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(); + 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); diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts index 5664e2f..a0c1277 100644 --- a/src/sim/quests.test.ts +++ b/src/sim/quests.test.ts @@ -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); diff --git a/src/sim/regions.test.ts b/src/sim/regions.test.ts index 448e085..8382936 100644 --- a/src/sim/regions.test.ts +++ b/src/sim/regions.test.ts @@ -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]!); }); diff --git a/src/sim/save.test.ts b/src/sim/save.test.ts index d62920b..acdeda0 100644 --- a/src/sim/save.test.ts +++ b/src/sim/save.test.ts @@ -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); } }); }); diff --git a/src/sim/save.ts b/src/sim/save.ts index b72a6f0..803ed8d 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -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; 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]; diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index f9a045f..ebd72d6 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -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)); diff --git a/src/sim/units.ts b/src/sim/units.ts index c135655..584f209 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -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 = { 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 diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 0775f28..f704f6a 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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'}`,