Cover comes from the road's reputation and the car you turned up in
Occupied ground used to blow your cover on its own: park anywhere past the line, in anything, and somebody would eventually make you. But nobody is looking for a driver they have no reason to believe exists. Suspicion now runs off how wary the place actually is - the heat on the road being used, or on the district when off it - and off how much the vehicle draws the eye. Two things had to change for that to mean anything. Alertness caps how sure anyone can get, not just how fast. Without the ceiling a quiet car on a cold road is only *slower* to be made, never safe from it, because everybody saturates eventually and the whole distinction collapses. And the decay runs the whole time rather than only when nobody is watching, which turns an accumulator into a threshold. Below DECAY-worth of attention you are not slowly being made, you are simply not being made. An accumulator always reaches the top given long enough, so "nobody suspects you here" could never have meant anything. Certainty and what certainty costs are kept apart. A person cannot be more than sure - so attention caps, and it is what the markers over their heads show - but somebody sure about an armoured carrier on a road they have worked all week is a different event from somebody equally sure about a tractor on a quiet one. Folding those together made every vehicle identical the moment anyone was certain, which took the garage's whole decision back out. Tuned against the stated pacing, and pinned to it. Driving the same checkpoint repeatedly as the road heats up, the pass you are caught on: armoured carrier 1st, sports coupe 2nd, lorry 4th, estate 5th, runabout 7th, tractor 8th. Under continuous watch an armoured carrier is made in half a second on a notorious road and still under six on one nobody has touched; a runabout on a cold road is never made at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5b881312b0
commit
4b1f8c6444
15
src/main.ts
15
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);
|
||||
|
||||
@ -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<PursuitStep>) => {
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<number>();
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user