From 2e0373fb273b3980a4c92ef02cdc6469bd8a1af4 Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 07:12:36 +0200 Subject: [PATCH] Killed in action: the decline finally has a floor A car could be driven to zero in every subsystem and would keep going forever at a wheezing thirty km/h. Nothing was at stake, so nothing was worth protecting, so there was never a reason to take the quiet road. "Decline, not reset" only reads as a stake if the decline can run out. When the chassis is gone, so are you: somebody pulls you out, you come round at the nearest base, the job in hand is written off with the car, and the ground your work had won is handed back - 25m, through the same path a finished mission uses, so the war moves for one reason only. The replacement is a real car, patched to its ceilings so it can be driven off the forecourt, and every ceiling is 8% down on the one you wrote off. That is the ratchet applied at the one moment repairs cannot reach. It stops at a floor: without one, enough deaths issue a car that is written off the instant you are handed it, and the only way out of the campaign is wiping it. A miserable car you can still drive is the interesting version of that ending. None of it is announced. The front moving against you is something you find out by driving back out there. Co-Authored-By: Claude Opus 5 --- src/main.ts | 64 +++++++++++++++++++++++++ src/sim/kia.test.ts | 108 +++++++++++++++++++++++++++++++++++++++++++ src/sim/kia.ts | 71 ++++++++++++++++++++++++++++ src/sim/quests.ts | 17 +++++++ src/sim/save.test.ts | 7 +++ src/sim/save.ts | 10 +++- src/ui/hud.ts | 10 +++- 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/sim/kia.test.ts create mode 100644 src/sim/kia.ts diff --git a/src/main.ts b/src/main.ts index c375351..f3f7229 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,11 +18,13 @@ import { createMinimap } from './ui/minimap'; import { accept, createQuests, + failQuest, MISSION_SHAPE, offersAt, stepQuest, type Offer, } from './sim/quests'; +import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia'; import { createRadio, pollRadio } from './sim/radio'; import { createOpportunities, stepOpportunities } from './sim/opportunities'; import { createHeatProps } from './heatProps'; @@ -194,6 +196,8 @@ async function boot() { let civilianDeaths = 0; /** Suspicion earned outright this step by something the player just did. */ let provocation = 0; + /** Times the car has been written off underneath the player. */ + let kia = 0; const say = (text: string, seconds = 4) => { notice = text; @@ -237,6 +241,7 @@ async function boot() { intel, front, quests, + kia, }; }; @@ -248,6 +253,7 @@ async function boot() { // the values that are not have to be copied back out by hand. elapsed = live.elapsed; condition = live.condition; + kia = live.kia; physics.chassis.setTranslation( { x: saved.car.position[0], y: saved.car.position[1], z: saved.car.position[2] }, true, @@ -296,6 +302,55 @@ async function boot() { captureChassis(); captureChassis(); + /** + * The car came apart around you. + * + * You wake up at whichever base was nearest, in a worse car than the one you + * wrote off, with the job you were carrying written off along with it and the + * ground it would have won handed back. Nothing about this is announced as a + * number — the front moving against you is something you find out about by + * driving back out there. + */ + const killedInAction = () => { + kia++; + const at = physics.chassis.translation(); + const home = bases.reduce((best, b) => + Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best, + ); + + const lost = failQuest(quests); + // Ground you had taken is given back. Negative impact, through the same + // path a finished mission uses, so the war moves for one reason only. + applyMissionImpact(front, -KIA_LAND_LOSS); + condition = replacementCar(condition); + + // Whoever was chasing is chasing a car that no longer exists. + for (const unit of units.units) unit.hunting = false; + pursuit.hunters.clear(); + pursuit.suspicion = 0; + pursuit.alert = 'clear'; + pursuit.lastSeen = null; + pursuit.unseenFor = 0; + + physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true); + physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true); + physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true); + physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true); + captureChassis(); + captureChassis(); + + say( + lost + ? `You woke up at ${home.name}. The car did not, and neither did the ${MISSION_SHAPE[ + lost.type + ].label.toLowerCase()}.` + : `You woke up at ${home.name}. Somebody else is driving what is left of the car.`, + 9, + ); + audio.impact(26000); + persistence.checkpoint(elapsed, snapshot()); + }; + document.getElementById('boot')?.remove(); const handlers: LoopHandlers = { @@ -627,6 +682,11 @@ async function boot() { if (elapsed > noticeUntil) say('Taking fire.', 2.5); } + // --- The end of a car --- + // Checked here rather than beside `applyWear`, because being shot at + // damages the chassis too and either route has to be able to finish you. + if (isWrittenOff(condition)) killedInAction(); + // --- Radio --- // Front drift is measured, not narrated: the chatter about the enemy // being busy elsewhere fires because the line really is moving. @@ -758,6 +818,8 @@ async function boot() { }, danger: dangerNear(combat, p.x, p.z, 90), completed: quests.completed, + failed: quests.failed, + kia, parts: quests.parts, notice: elapsed < noticeUntil ? notice : '', resetProgress: resetHeld / RESET_HOLD_SECONDS, @@ -806,6 +868,8 @@ async function boot() { onTarmac, parts: quests.parts, completed: quests.completed, + failed: quests.failed, + kia, condition, front: front.boundaries, opportunity: opportunities.current, diff --git a/src/sim/kia.test.ts b/src/sim/kia.test.ts new file mode 100644 index 0000000..79e90e4 --- /dev/null +++ b/src/sim/kia.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { applyWear, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car'; +import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './kia'; +import { createFront, applyMissionImpact, depthAt } from './regions'; +import { accept, createQuests, failQuest, type Offer } from './quests'; + +const at = (chassis: number): CarCondition => ({ + level: { engine: 0.5, tires: 0.5, chassis }, + ceiling: { engine: 0.8, tires: 0.8, chassis: 0.8 }, +}); + +describe('being written off', () => { + it('is the chassis that kills you, not the engine or the tyres', () => { + expect(isWrittenOff(at(0))).toBe(true); + expect(isWrittenOff(at(0.01))).toBe(false); + expect( + isWrittenOff({ + level: { engine: 0, tires: 0, chassis: 0.4 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }), + ).toBe(false); + }); + + it('is reachable — a car can actually be driven into the ground', () => { + let c = freshCondition(); + for (let i = 0; i < 400 && !isWrittenOff(c); i++) { + c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2.5e6 }); + } + expect(isWrittenOff(c)).toBe(true); + }); +}); + +describe('the replacement car', () => { + it('is handed over running, or it would kill you again immediately', () => { + const next = replacementCar(at(0)); + expect(isWrittenOff(next)).toBe(false); + for (const part of SUBSYSTEMS) { + expect(next.level[part]).toBeCloseTo(next.ceiling[part], 9); + } + }); + + it('is always worse than the one you wrote off', () => { + const before = at(0); + const next = replacementCar(before); + for (const part of SUBSYSTEMS) { + expect(next.ceiling[part]).toBeLessThan(before.ceiling[part]); + } + }); + + it('ratchets down over a long campaign without ever becoming undriveable', () => { + let c = freshCondition(); + let previous = 1; + for (let death = 0; death < 40; death++) { + c = replacementCar({ ...c, level: { ...c.level, chassis: 0 } }); + expect(c.ceiling.chassis).toBeLessThanOrEqual(previous); + previous = c.ceiling.chassis; + // Whatever else is true, you can always get back on the road. + expect(isWrittenOff(c)).toBe(false); + expect(repair(c, 99).condition.level.chassis).toBeGreaterThan(0); + } + // Twenty-odd deaths in and the car is a genuinely miserable thing to drive. + expect(previous).toBeLessThan(0.35); + }); +}); + +describe('what a death costs', () => { + it('gives ground back to the enemy', () => { + const front = createFront(11, 400, { x: 0, z: 0 }); + const before = front.boundaries.liberated; + applyMissionImpact(front, -KIA_LAND_LOSS); + expect(front.boundaries.liberated).toBeLessThan(before); + }); + + it('loses the job in hand without counting it as a run', () => { + const quests = createQuests(); + const offer = { + id: 1, + type: 'supply', + targetNode: 4, + novel: true, + route: { nodes: [0, 4], segments: [0], length: 900 }, + intel: { worst: 'clear', unknownCount: 1, stalest: null }, + reward: 0.3, + impact: 15, + } as unknown as Offer; + accept(quests, offer, { nodeId: 0, x: 0, z: 0, hutX: 0, hutZ: 0, name: 'Anvil' }); + + const lost = failQuest(quests); + expect(lost?.type).toBe('supply'); + expect(quests.active).toBeNull(); + expect(quests.completed).toBe(0); + expect(quests.failed).toBe(1); + expect(quests.parts).toBe(0); + }); + + it('is a setback, not a campaign reversal', () => { + // Sanity on the scale: a death should cost less ground than a couple of + // decent missions win, or dying once undoes an evening's work. + const front = createFront(3, 400, { x: 0, z: 0 }); + const start = depthAt(front, 0, 0); + void start; + applyMissionImpact(front, 40); + const won = front.boundaries.liberated; + applyMissionImpact(front, -KIA_LAND_LOSS); + expect(front.boundaries.liberated).toBeLessThan(won); + expect(front.boundaries.liberated).toBeGreaterThan(won - 40); + }); +}); diff --git a/src/sim/kia.ts b/src/sim/kia.ts new file mode 100644 index 0000000..e189374 --- /dev/null +++ b/src/sim/kia.ts @@ -0,0 +1,71 @@ +/** + * Being killed in action. Pure — no engine imports. + * + * Until now the decline had no floor. A car could be driven to nothing in every + * subsystem and would keep going forever at a wheezing thirty kilometres an + * hour, which meant there was nothing to protect and therefore no reason to + * take the quiet road. "Decline, not reset" only reads as a stake if the + * decline can actually run out. + * + * So: when the chassis is gone, you are gone. Somebody pulls you out, you wake + * up at a friendly base, the job you were carrying is not finished and never + * will be, and the ground your work had won gets pushed back. The war does not + * pause for it. + * + * What it is deliberately *not* is a game over screen. The campaign is the + * thing being ratcheted down; ending it outright would throw away every mile of + * wear and every road the enemy learned, which is exactly what the five-second + * reset hold exists to make hard to do by accident. + */ +import { SUBSYSTEMS, type CarCondition } from './car'; + +/** + * Metres of front line handed back when you are killed. + * + * Small on purpose. It is the cost of one bad run, not a campaign reversal — + * roughly two missions' worth of progress, against `IMPACT_BASE` of 5m plus + * 11m/km in sim/quests.ts. Big enough to notice on the map the next time you + * pass, and it never announces itself: you find out by going back and looking. + */ +export const KIA_LAND_LOSS = 25; + +/** + * How much of every subsystem's ceiling the replacement car is down on the one + * you wrote off. + * + * You do not get your car back — you get *a* car, and the insurgency's next one + * is always a little worse than the last. This is the ratchet the pillar asks + * for, applied at the one moment the player cannot repair their way out of. + */ +const CEILING_LOSS = 0.08; + +/** + * The worst car anyone will hand you. + * + * Strictly this breaks "the ceiling only ever falls", and it is worth being + * honest about why. Without a floor, enough deaths leave a chassis ceiling of + * zero — which is a car that is written off the instant it is issued, and a + * campaign that can only ever be escaped by wiping it. A floor turns the late + * game into a genuinely miserable car you can still drive, which is the + * interesting version of that ending rather than the broken one. + */ +const MIN_CEILING = 0.2; + +/** The chassis is what kills you. Tyres and engine only ever strand you. */ +export const isWrittenOff = (c: CarCondition): boolean => c.level.chassis <= 0; + +/** + * The car you are handed after they scrape you out of the last one: patched up + * to whatever it is still capable of, and capable of a little less than before. + */ +export function replacementCar(c: CarCondition): CarCondition { + const ceiling = { ...c.ceiling }; + const level = { ...c.level }; + for (const part of SUBSYSTEMS) { + ceiling[part] = Math.max(MIN_CEILING, c.ceiling[part] - CEILING_LOSS); + // Handed over running. A replacement that still needed the parts you do not + // have would just kill you again on the way out of the yard. + level[part] = ceiling[part]; + } + return { level, ceiling }; +} diff --git a/src/sim/quests.ts b/src/sim/quests.ts index 874a41a..df6b317 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -95,6 +95,8 @@ export interface ActiveQuest extends Offer { export interface QuestState { active: ActiveQuest | null; completed: number; + /** Jobs dropped or lost with the car. Counted separately: they are not runs. */ + failed: number; /** Unspent repair parts. */ parts: number; nextId: number; @@ -103,10 +105,25 @@ export interface QuestState { export const createQuests = (): QuestState => ({ active: null, completed: 0, + failed: 0, parts: 0, nextId: 1, }); +/** + * Drops the job in hand, paying nothing. + * + * Used both when the player walks away from one at a base and when the car is + * written off underneath them. Returns what was lost so the caller can say so. + */ +export function failQuest(state: QuestState): ActiveQuest | null { + const quest = state.active; + if (!quest) return null; + state.active = null; + state.failed++; + return quest; +} + /** How close counts as "there", for both targets and bases. */ export const ARRIVAL_RADIUS = 18; /** diff --git a/src/sim/save.test.ts b/src/sim/save.test.ts index acdeda0..53c6993 100644 --- a/src/sim/save.test.ts +++ b/src/sim/save.test.ts @@ -28,6 +28,7 @@ function played(): Snapshot { for (let i = 0; i < 60 * 45; i++) stepFront(front, 1 / 60); applyMissionImpact(front, 18); quests.completed = 3; + quests.failed = 1; quests.parts = 0.42; quests.nextId = 7; @@ -40,6 +41,7 @@ function played(): Snapshot { intel, front, quests, + kia: 2, }; } @@ -55,6 +57,7 @@ function blank(): Snapshot { intel: createIntel(world.roads, world.extent), front, quests: createQuests(), + kia: 0, }; } @@ -74,7 +77,11 @@ describe('save round trip', () => { expect(restored.heat.value).toEqual(source.heat.value); expect(restored.heat.level).toEqual(source.heat.level); expect(restored.quests.completed).toBe(3); + expect(restored.quests.failed).toBe(1); expect(restored.quests.parts).toBeCloseTo(0.42, 9); + // A campaign's history of written-off cars has to outlive the tab, or the + // ratchet the KIA rule exists to drive resets every time you close it. + expect(restored.kia).toBe(2); }); it('brings back the ceiling, not just the current condition', () => { diff --git a/src/sim/save.ts b/src/sim/save.ts index 803ed8d..0b17f44 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 = 2; +export const SAVE_VERSION = 3; export interface CarSnapshot { position: [number, number, number]; @@ -46,9 +46,12 @@ export interface SaveData { quests: { active: ActiveQuest | null; completed: number; + failed: number; parts: number; nextId: number; }; + /** Times the car has been written off underneath the player. */ + kia: number; } export interface Snapshot { @@ -60,6 +63,7 @@ export interface Snapshot { intel: Intel; front: Front; quests: QuestState; + kia: number; } export function serialise(s: Snapshot): SaveData { @@ -84,9 +88,11 @@ export function serialise(s: Snapshot): SaveData { quests: { active: s.quests.active, completed: s.quests.completed, + failed: s.quests.failed, parts: s.quests.parts, nextId: s.quests.nextId, }, + kia: s.kia, }; } @@ -139,6 +145,8 @@ export function apply(data: SaveData, into: Snapshot): void { into.quests.active = data.quests.active; into.quests.completed = data.quests.completed; + into.quests.failed = data.quests.failed; into.quests.parts = data.quests.parts; into.quests.nextId = data.quests.nextId; + into.kia = data.kia; } diff --git a/src/ui/hud.ts b/src/ui/hud.ts index f1de017..a25fdc9 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -50,6 +50,10 @@ export interface HudModel { /** Rounds in the air nearby. Not a health bar — a reason to keep moving. */ danger: number; completed: number; + /** Jobs dropped or lost with a car. */ + failed: number; + /** Cars written off underneath the player. */ + kia: number; parts: number; notice: string; /** 0..1 toward wiping the campaign, while R is held. */ @@ -126,7 +130,11 @@ export function createHud(seed: number) { `${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` + `${(condition.level[part] * 100).toFixed(0)}%`, ), - `parts ${model.parts.toFixed(2)} runs ${model.completed}`, + `parts ${model.parts.toFixed(2)} runs ${model.completed}` + + (model.failed > 0 ? ` lost ${model.failed}` : '') + + // Cars are counted, not lives. There is no stock of them to run out + // of; the number is there so a long campaign reads as a history. + (model.kia > 0 ? ` cars ${model.kia + 1}` : ''), '', ...(model.quest ? questLines(model.quest) : ['no mission — find a base']), '',