diff --git a/src/main.ts b/src/main.ts index 1ea7962..ba86a0c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -877,6 +877,21 @@ async function boot() { units, canSee, provocation, + /* + * How wary this place is: the heat on the road being used, or on + * the district when off it. + * + * This is what finally makes heat a *continuous* pressure rather + * than something that only exists at the thresholds where concrete + * appears. Use one route all week and the people on it start + * expecting you; take a cold road and a car going past is a car + * going past. + */ + wariness: + currentSegment === null + ? (heat.area[areaAt(areas, at.x, at.z) ?? 0] ?? 0) + : effectiveHeat(heat, areas, currentSegment), + presence: car.presence, }); for (const event of noticed) { if (event.kind === 'recognised') say('They have made you. Drive.', 6); diff --git a/src/sim/pursuit.test.ts b/src/sim/pursuit.test.ts index 8fb93b5..1d3341a 100644 --- a/src/sim/pursuit.test.ts +++ b/src/sim/pursuit.test.ts @@ -10,6 +10,7 @@ import { type PursuitStep, } from './pursuit'; import { createUnits, type Faction, type Unit, type UnitState } from './units'; +import { carById } from './garage'; import type { Control } from './regions'; function place(state: UnitState, faction: Faction, x: number, z: number): Unit { @@ -50,6 +51,10 @@ function run( units, canSee: () => true, provocation: 0, + // A road that has been worked, in an ordinary car: the conditions every + // existing test in this file was written against. + wariness: 1, + presence: 1, ...overrides, })) { events.push(event.kind); @@ -132,6 +137,8 @@ describe('being noticed', () => { units, canSee: () => true, provocation: PROVOCATION.ranOverSoldier, + wariness: 0, + presence: 1, }); expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2); }); @@ -339,3 +346,133 @@ describe('who is looking at you', () => { expect(state.alert).toBe('hunted'); }); }); + +describe('cover depends on the place and the car', () => { + /** Somebody standing twenty metres away, watching, for a given few seconds. */ + const watched = (seconds: number, overrides: Partial) => { + const units = createUnits(); + place(units, 'enemy', 20, 0); + const state = createPursuit(); + run(state, units, seconds, overrides); + return state; + }; + + it('barely notices you on a road nobody has been working', () => { + // The complaint this answers: occupied ground blew your cover on its own. + // Nobody is looking for a driver they have no reason to think exists. + // Ten seconds: on a maximally notorious road, in an ordinary car, parked + // twenty metres from somebody, that is about how long being made takes. + const cold = watched(10, { wariness: 0, presence: 1 }); + const hot = watched(10, { wariness: 1, presence: 1 }); + expect(cold.suspicion).toBeLessThan(hot.suspicion / 5); + expect(cold.alert).toBe('clear'); + expect(hot.alert).toBe('hunted'); + }); + + it('is why using one road all week costs you', () => { + // Heat used to matter only at the thresholds where concrete appears. It is + // now a continuous pressure: the people on your favourite route are + // expecting you. + const quiet = watched(5, { wariness: 0.15, presence: 1 }); + const notorious = watched(5, { wariness: 0.9, presence: 1 }); + expect(notorious.suspicion).toBeGreaterThan(quiet.suspicion * 3); + }); + + it('makes the vehicle you chose part of the same sum', () => { + const tractor = watched(6, { wariness: 0.5, presence: 0.45 }); + const carrier = watched(6, { wariness: 0.5, presence: 3.8 }); + expect(tractor.suspicion).toBeLessThan(carrier.suspicion / 4); + }); + + it('lets a quiet car on a cold road go about its business indefinitely', () => { + // The whole point of the cheap end of the garage. + const state = watched(45, { wariness: 0, presence: 0.45 }); + expect(state.alert).toBe('clear'); + expect(state.suspicion).toBeLessThan(0.3); + }); + + it('gets an armoured carrier made almost at once on a notorious road', () => { + // And the whole point of the expensive end: it buys survival, not cover. + const state = watched(4, { wariness: 1, presence: 3.8 }); + expect(state.alert).toBe('hunted'); + }); + + it('still lets nobody watch you behind your own lines, whatever you drive', () => { + const state = watched(30, { control: 'liberated', wariness: 1, presence: 3.8 }); + expect(state.suspicion).toBe(0); + expect(state.alert).toBe('clear'); + }); + + it('shows the difference on the faces before it costs you anything', () => { + // The place and the car ride on the *focus* ramp, not on suspicion + // directly, so the markers over people's heads redden faster on a hot road + // — you can read the road's reputation off the crowd. + const cold = watched(2, { wariness: 0, presence: 1 }); + const hot = watched(2, { wariness: 1, presence: 1 }); + expect(Math.max(...hot.eyes.values())).toBeGreaterThan( + Math.max(...cold.eyes.values()) * 3, + ); + }); +}); + +describe('the pacing the garage is meant to produce', () => { + /** + * A checkpoint you drive past: in sight for a few seconds, then gone. + * + * `wariness` is the road's heat, and heat is what accumulates between passes + * — the suspicion meter itself decays back to nothing, deliberately. So the + * memory of "this car keeps coming through here" lives on the road rather + * than in the guard's head, which is what makes "use a different route" a + * decision about cover and not just about concrete. + */ + const drivePast = (state: PursuitState, presence: number, wariness: number) => { + const units = createUnits(); + const guard = place(units, 'enemy', 14, 0); + run(state, units, 5, { presence, wariness }); + // Checked here, in front of the checkpoint — not after driving away. + // Twenty seconds down the road you have always shaken them, so asking at + // the end of the cycle is asking whether you got away, which is a + // different question and always has the same answer. + const made = state.alert === 'hunted'; + guard.x = 9000; + run(state, units, 20, { presence, wariness }); + return made; + }; + + it('has an armoured carrier made almost at once', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', 14, 0); + // A road nobody has even been working. It does not help. + run(state, units, 3, { presence: carById('apc').presence, wariness: 0.1 }); + expect(state.alert).toBe('hunted'); + }); + + it('lets a small car through the same checkpoint several times first', () => { + const state = createPursuit(); + const small = carById('runabout').presence; + // Each pass leaves the road a little warier than the last. + // Heat climbs the way repeated use of one road actually climbs it. + const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1]; + const caughtOn = passes.findIndex((w) => drivePast(state, small, w)); + // Several trips through before the place works out that this car keeps + // coming through — and not never, or the cheap car would be a free pass. + expect(caughtOn).toBeGreaterThan(2); + expect(caughtOn).toBeLessThan(passes.length); + }); + + it('catches the flashy car far sooner than the dull one', () => { + const runsBefore = (presence: number) => { + const state = createPursuit(); + const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1]; + const at = passes.findIndex((w) => drivePast(state, presence, w)); + return at === -1 ? passes.length : at; + }; + expect(runsBefore(carById('sports').presence)).toBeLessThan( + runsBefore(carById('runabout').presence), + ); + expect(runsBefore(carById('tractor').presence)).toBeGreaterThanOrEqual( + runsBefore(carById('estate').presence), + ); + }); +}); diff --git a/src/sim/pursuit.ts b/src/sim/pursuit.ts index 40a5660..0651d2e 100644 --- a/src/sim/pursuit.ts +++ b/src/sim/pursuit.ts @@ -50,8 +50,17 @@ export const createPursuit = (): PursuitState => ({ /** How far an enemy can make you out at all. */ export const SIGHT_RANGE = 95; -/** Suspicion per second from somebody who has settled on you, right on top of you. */ -const MAX_GAIN = 0.42; +/** + * Suspicion per second from somebody certain about you, right on top of you, + * on ground and in a vehicle that both fully deserve it. + * + * Large, because it is multiplied down hard by `alertness` in almost every real + * situation. The pacing it is set against is concrete: an armoured carrier is + * made within a couple of seconds even on a road nobody has been working, and a + * small unremarkable car can go through the same checkpoint several times + * before the road has learned enough about it to matter. + */ +const MAX_GAIN = 1.6; /** * How fast somebody goes from noticing a car to being sure about it, per second, * with the car right beside them. @@ -67,16 +76,36 @@ const MAX_GAIN = 0.42; * to the point — makes the first half of that time legible. */ const FOCUS_RATE = 0.55; +/** + * How much attention a place pays a stranger when nothing has happened there. + * + * The floor under `wariness`, and the point of the whole mechanism. Occupied + * ground used to blow your cover on its own: park anywhere past the line, in + * anything, and someone would eventually make you. But nobody is looking for a + * driver they have no reason to believe exists — if no road round here has been + * worked and no district has a story about you, a car going past is a car going + * past. + * + * Above zero because occupied ground is still occupied: sit in front of a + * checkpoint long enough and somebody will wander over regardless. + */ +const COLD_GROUND = 0.12; /** How fast that interest fades once they cannot see you. Quicker than suspicion. */ const FOCUS_FADE = 0.7; /** - * Suspicion shed per second with nobody watching. + * Suspicion shed per second, at all times. * - * Slow on purpose. Cover is meant to be something you lose and then have to - * earn back by staying dull for a while; at a brisk decay rate you are clean - * again before you have finished the corner. + * Slow on purpose, and for two reasons now. Cover is meant to be something you + * lose and then have to earn back by being dull for a while. And because it + * runs continuously it is also the *threshold*: below DECAY worth of attention + * you are not slowly being made, you are simply not being made. + * + * Small enough that a little survives the gap between one trip past a + * checkpoint and the next. That residue is what lets a small car go through + * several times before the place has worked out that it keeps coming through — + * the road's own heat does most of that remembering, but not all of it. */ -const DECAY = 0.05; +const DECAY = 0.02; /** Suspicion at which you stop being a car and start being a target. */ const RECOGNISED = 1; /** Above this the HUD starts warning you. */ @@ -128,6 +157,20 @@ export interface PursuitStep { canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean; /** Suspicion added outright this step by things the player just did. */ provocation: number; + /** + * How wary this particular place is, 0..1 — the heat on the road being used, + * or on the district when off it. + * + * This is what makes "take a different road" a decision about *cover* rather + * than only about concrete. Use one route all week and the people on it are + * expecting you. + */ + wariness: number; + /** + * How much the vehicle draws the eye. A tractor is part of the scenery; an + * armoured carrier is a thing people phone in. + */ + presence: number; } export type PursuitEvent = @@ -152,13 +195,38 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven // --- Who is looking, and how settled they are about it --- // Everyone who can see you creeps toward being sure; everyone who cannot // loses interest, faster than they gained it. + /* + * How much trouble this is, all in one number: whose ground it is, how wary + * the place has been made, and what you turned up in. + * + * All three belong on the *focus* ramp rather than on suspicion directly, + * because that is the half the player can see. A hot road does not silently + * fill a meter faster — the diamonds over people's heads redden faster, and + * you can read the road's reputation off the crowd before it costs you + * anything. + */ + const wariness = COLD_GROUND + (1 - COLD_GROUND) * Math.min(1, Math.max(0, step.wariness)); + const alertness = exposure * wariness * step.presence; + const looking = new Set(); let attention = 0; for (const unit of seenBy) { looking.add(unit.id); const gap = Math.hypot(unit.x - step.player.x, unit.z - step.player.z); const proximity = (1 - gap / SIGHT_RANGE) ** 2; - const settled = Math.min(1, (state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * step.dt); + /* + * Alertness caps how sure anyone can get, not merely how fast. + * + * Without the ceiling a quiet car on a cold road is only *slower* to blow + * your cover, never safe from it — everybody eventually saturates and the + * whole distinction collapses. With it, somebody with no reason to suspect + * a driver exists tops out at a glance and stays there. + */ + const ceiling = Math.min(1, alertness); + const settled = Math.min( + ceiling, + (state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * alertness * step.dt, + ); state.eyes.set(unit.id, settled); // Whoever has the best look at you sets the pace; a crowd is not more // suspicious than one person standing right next to the car. @@ -171,11 +239,28 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven else state.eyes.set(id, faded); } - // --- Suspicion --- - if (exposure > 0 && attention > 0) { - state.suspicion += attention * MAX_GAIN * exposure * step.dt; - } else if (state.alert !== 'hunted') { - state.suspicion -= DECAY * step.dt; + /* + * Suspicion, as a race between being looked at and being forgotten. + * + * Two different things, kept apart on purpose. `attention` is how *sure* the + * keenest onlooker is, and a person cannot be more than certain — so it caps, + * and it is what the markers over their heads show. `alertness` is what that + * certainty costs you here, and it does not cap at all: somebody certain + * about an armoured carrier on a road they have been working all week is a + * different event from somebody equally certain about a tractor on a quiet + * one. Folding the two together made every vehicle identical the moment + * anybody was sure about it, which took the garage's whole decision back out. + * + * The decay runs the whole time rather than only when nobody is watching, and + * that is what turns this from an accumulator into a threshold. Below + * DECAY/MAX_GAIN of attention you are not slowly being made, you are simply + * *not being made* — a tractor on a road nobody has worked can go about its + * business all day. Above it, the clock is running. An accumulator has no + * such line: given long enough it always reaches the top, and "nobody + * suspects you here" could never mean anything. + */ + if (state.alert !== 'hunted') { + state.suspicion += (attention * alertness * MAX_GAIN - DECAY) * step.dt; } // Provocations land whatever the range, and they land in full: driving over