diff --git a/README.md b/README.md index f57038f..a43a720 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/debug.ts b/src/debug.ts index 173b512..d6760ce 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -36,6 +36,60 @@ export interface DebugApi { state: Record; info: () => Record; 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'); } diff --git a/src/main.ts b/src/main.ts index b0108a7..6714673 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index ebd72d6..95456e0 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -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)); diff --git a/src/sim/units.ts b/src/sim/units.ts index 584f209..08cae8b 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -115,6 +115,16 @@ const SPEED: Record = { 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';