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']), '',