Patrols settle their district too, slowly, and a debug lever for condition

A patrol working a road now takes a little off the district around it as well,
at roughly a tenth of the rate it takes off the road. Deliberately feeble: any
faster and sitting still while patrols came and went would quietly launder your
reputation, and district heat exists precisely to be the part you cannot patrol
away. Passed as a position rather than a district id, so the unit sim still does
not need to know how areas are gridded.

Adds a debug panel under ?debug=1 with buttons to wear the car down or patch it
up. Driving a wrecked car is something you want to try while driving, not while
typing at a console. An earlier version of the console helper took an amount and
ignored it, which would have made it silently useless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-08 12:11:53 +02:00
parent 9eaa05d7ef
commit 5e557dc93a
5 changed files with 141 additions and 0 deletions

View File

@ -77,6 +77,10 @@ bleeds into every road running through it, including ones you have never driven.
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.
- A patrol working a road **does** settle the district around it, at about a
tenth of the rate it settles the road. Any faster and waiting out a patrol
would launder your reputation, and district heat is meant to be the part you
cannot patrol away.
### Speed is conspicuous
@ -271,6 +275,17 @@ is permanent in the same way everything else is.
a target. They open up only past the line *and* on a road they have already
checkpointed — that is, a road you personally made notorious.
## Debug
Load with `?debug=1` for a `window.__dbtl` handle and a small panel in the
corner. The panel has `` / `+` buttons for the car's condition, because
"drive a wrecked car for a minute" is something you want to do *while driving*
rather than while typing into a console.
The handle also steps the simulation by hand, renders on demand, and writes
frames to `shots/` via a dev-only endpoint — which is how this build gets looked
at at all when the tab is not compositing. `__dbtl.state` exposes the live sim.
## Sound
Everything is **synthesised** — there are no audio files. The build has to stay

View File

@ -36,6 +36,60 @@ export interface DebugApi {
state: Record<string, unknown>;
info: () => Record<string, unknown>;
teleport: (x: number, z: number) => void;
condition: () => { level: { engine: number; tires: number; chassis: number } };
setCondition: (level: number) => unknown;
}
/**
* An on-screen panel for the levers worth pulling by hand.
*
* The console handle covers everything, but "drive a wrecked car for a minute"
* is a thing you want to do *while driving*, not while typing so condition
* gets buttons. Only ever mounted under `?debug=1`.
*/
function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: DebugApi): void {
const panel = document.createElement('div');
panel.id = 'debug-panel';
panel.style.cssText = `
position: fixed; right: 12px; bottom: 12px; z-index: 10;
display: flex; gap: 6px; align-items: center;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
color: #cfd6dd; background: rgba(12,15,19,.9);
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
`;
const readout = document.createElement('span');
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;';
const refresh = () => {
const { engine, tires, chassis } = api.condition().level;
readout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
};
const button = (label: string, by: number) => {
const element = document.createElement('button');
element.textContent = label;
element.style.cssText = `
font: inherit; color: inherit; cursor: pointer;
background: #1b222b; border: 1px solid #37414d; border-radius: 3px;
padding: 3px 9px;
`;
element.addEventListener('click', () => {
debug.nudgeCondition(by);
refresh();
// Keep the keyboard on the game, or space starts pressing this button.
element.blur();
});
panel.append(element);
};
panel.append(document.createTextNode('debug'));
button('', -0.15);
button('+', +0.15);
panel.append(readout);
document.body.append(panel);
refresh();
setInterval(refresh, 500);
}
export function exposeDebug(api: DebugApi): void {
@ -107,6 +161,13 @@ export function exposeDebug(api: DebugApi): void {
},
teleport: api.teleport,
/** 0 is a wreck, 1 is factory fresh. */
setCondition: api.setCondition,
/** Nudge condition up or down, for feeling out how decline drives. */
nudgeCondition(by: number) {
const now = api.condition().level;
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
},
info: api.info,
state: api.state,
three: THREE,
@ -114,5 +175,6 @@ export function exposeDebug(api: DebugApi): void {
};
(window as unknown as { __dbtl: typeof debug }).__dbtl = debug;
mountPanel(debug, api);
console.info('debug handle ready: window.__dbtl');
}

View File

@ -495,6 +495,10 @@ async function boot() {
decayHeat: (id, amount) => {
heat.value[id] = Math.max(0, heat.value[id]! - amount);
},
decayArea: (x, z, amount) => {
const cell = areaAt(areas, x, z);
if (cell !== null) heat.area[cell] = Math.max(0, heat.area[cell]! - amount);
},
},
model.roads,
graph,
@ -711,6 +715,25 @@ async function boot() {
physics,
// Live state, so a script can find something interesting and go look at it.
state: { units, combat, heat, areas, intel, quests, front, model, bases },
condition: () => condition,
/**
* Force the car's condition, for looking at how a wrecked car drives
* without having to spend twenty minutes wrecking one.
*/
setCondition(level: number) {
const clamped = Math.max(0, Math.min(1, level));
condition = {
level: { engine: clamped, tires: clamped, chassis: clamped },
// Raising the ceiling too, so this can undo damage as well as cause
// it. It is a debug lever, not a repair: nothing in the game does this.
ceiling: {
engine: Math.max(clamped, condition.ceiling.engine),
tires: Math.max(clamped, condition.ceiling.tires),
chassis: Math.max(clamped, condition.ceiling.chassis),
},
};
return condition;
},
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);

View File

@ -45,6 +45,7 @@ function run(
front,
heatLevel: () => 'turret',
decayHeat: () => {},
decayArea: () => {},
...overrides,
},
world.roads,
@ -127,6 +128,29 @@ describe('patrols are dispatched, not conjured', () => {
expect(cooled).toBeGreaterThan(PATROL_HEAT_DECAY * 20);
});
it('settles the district it is working, but only barely', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
let road = 0;
let area = 0;
run(state, 120, {
player: { x: segment.ax, z: segment.az },
decayHeat: (_, amount) => {
road += amount;
},
decayArea: (_x, _z, amount) => {
area += amount;
},
});
expect(area).toBeGreaterThan(0);
// A patrol re-secures the road it was sent to. It barely touches how the
// district feels about you — otherwise waiting out a patrol would launder
// your reputation, and district heat is meant to be the part you cannot
// patrol away.
expect(area).toBeLessThan(road / 5);
});
it('leaves once it has done its rounds', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));

View File

@ -115,6 +115,16 @@ const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */
export const PATROL_HEAT_DECAY = 0.005;
/**
* District heat a patrol settles per second, just by being present.
*
* An order of magnitude below the road figure, on purpose. A patrol re-secures
* the road it was sent to; it barely touches how the district feels about you.
* If this were anywhere near the road rate, sitting still while patrols came and
* went would quietly launder your reputation, and the whole point of district
* heat is that it is the thing you cannot patrol away.
*/
export const PATROL_AREA_DECAY = 0.0005;
/**
* 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
@ -423,6 +433,11 @@ export interface UnitStep {
heatLevel: (segmentId: number) => string;
/** Called when a patrol is working a road, to cool it down. */
decayHeat: (segmentId: number, amount: number) => void;
/**
* Called for the district a patrol is standing in. Takes a position rather
* than a district id so this file never has to know how areas are gridded.
*/
decayArea: (x: number, z: number, amount: number) => void;
}
export interface UnitEvents {
@ -519,6 +534,8 @@ export function stepUnits(
// Working the road is what brings it back down. The enemy is not
// punishing you; they are re-securing a route and then leaving.
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
// Presence settles the district a little too, but only a little.
step.decayArea(unit.x, unit.z, PATROL_AREA_DECAY * dt);
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
} else {
const stage: BuildStage = unit.building ?? 'barricade';