diff --git a/src/core/input.ts b/src/core/input.ts index 5673e34..5387b01 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -11,6 +11,8 @@ export interface DriverInput { toggleMute: boolean; /** True on the frame X was pressed: give up the job in hand. Consumed on read. */ abandon: boolean; + /** True on the frame C was pressed: overhaul the car. Consumed on read. */ + overhaul: boolean; } const KEYS = { @@ -22,6 +24,7 @@ const KEYS = { respawn: ['KeyR'], mute: ['KeyM'], abandon: ['KeyX'], + overhaul: ['KeyC'], } as const; const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4']; @@ -38,6 +41,7 @@ export function createInput(): { let pendingSelect: number | null = null; let pendingMute = false; let pendingAbandon = false; + let pendingOverhaul = false; /** Called on the first real interaction, to satisfy autoplay policy. */ let onFirstGesture: (() => void) | null = null; @@ -49,6 +53,7 @@ export function createInput(): { if (!down.has(e.code)) { if (e.code === 'KeyM') pendingMute = true; if (e.code === 'KeyX') pendingAbandon = true; + if (e.code === 'KeyC') pendingOverhaul = true; const index = SELECT_KEYS.indexOf(e.code); if (index >= 0) pendingSelect = index + 1; } @@ -63,6 +68,7 @@ export function createInput(): { pendingSelect = null; pendingMute = false; pendingAbandon = false; + pendingOverhaul = false; }; window.addEventListener('keydown', onDown); @@ -74,9 +80,11 @@ export function createInput(): { const select = pendingSelect; const toggleMute = pendingMute; const abandon = pendingAbandon; + const overhaul = pendingOverhaul; pendingSelect = null; pendingMute = false; pendingAbandon = false; + pendingOverhaul = false; return { throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), @@ -85,6 +93,7 @@ export function createInput(): { select, toggleMute, abandon, + overhaul, }; }, diff --git a/src/main.ts b/src/main.ts index 3da3d0b..5637e93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,14 @@ import * as THREE from 'three'; import { generateWorld } from './sim/world'; -import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car'; +import { + applyWear, + canOverhaul, + deriveHandling, + freshCondition, + overhaul, + repair, + surfaceFor, +} from './sim/car'; import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat'; import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; @@ -465,6 +473,44 @@ async function boot() { const base = baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; + // --- Overhaul: buying back what the car is capable of --- + // Offered whether or not there is a job in hand, since a workshop does + // not care what you are carrying. Deliberately a bad rate next to an + // ordinary repair — this rebuilds what the car *could* be, which is the + // expensive thing you do once patching it up has stopped helping. + if (base && stopped && cmd.overhaul) { + if (!canOverhaul(condition)) { + say('Nothing to rebuild. She is as good as she ever was.', 4); + } else if (quests.parts < 0.05) { + say('No parts to work with. Bring something back first.', 4); + } else { + const before = condition.ceiling; + const done = overhaul(condition, quests.parts); + const spent = quests.parts - done.unused; + condition = done.condition; + quests.parts = done.unused; + const gained = + condition.ceiling.engine + + condition.ceiling.tires + + condition.ceiling.chassis - + (before.engine + before.tires + before.chassis); + // A car that has turned a wheel is never *exactly* at factory, so the + // sim will happily sell you a hundredth of a percent. Judge by what + // the player can actually see rather than by what is left to buy. + if (gained < 0.005) { + say('Nothing worth stripping her down for. She is near enough new.', 4); + } else { + say( + `Stripped and rebuilt. ${spent.toFixed(2)} parts, and she will take ` + + `${(gained * 100).toFixed(0)}% more than she would have.`, + 7, + ); + audio.ui('accept'); + persistence.checkpoint(elapsed, snapshot()); + } + } + } + // --- Quest board: only when parked at a base with nothing in hand --- if (base && stopped && !quests.active) { if (openBase !== base.nodeId) { diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index 033b772..e9f4fef 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -21,6 +21,7 @@ const IDLE: DriverInput = { select: null, toggleMute: false, abandon: false, + overhaul: false, }; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ diff --git a/src/sim/car.ts b/src/sim/car.ts index aae6862..faee690 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -1,9 +1,15 @@ /** * Car condition and what it does to handling. Pure — no engine imports. * - * Design pillar this serves: "Decline, not reset." Repairs exist, but every - * subsystem carries a *ceiling* that only ever falls. Patching a car brings it - * back up to what it is still capable of, which is never quite what it was. + * Design pillar this serves: "Decline, not reset." Every subsystem carries a + * *ceiling*, and ordinary repair brings a car back up to what it is still + * capable of, which is never quite what it was. + * + * The ceiling used to fall and nothing else — the only number in the game that + * moved one way, with no counterplay at any price. In practice that read less + * as a pillar than as a punishment you could not answer. `overhaul` is the + * answer: decline is now something you can fight rather than only absorb, and + * what keeps it meaningful is the exchange rate rather than the impossibility. */ export interface Subsystems { @@ -210,6 +216,68 @@ export function repair(c: CarCondition, parts: number): { condition: CarConditio return { condition: { level, ceiling: c.ceiling }, unused: remaining }; } +/** + * Ceiling bought back per part spent on an overhaul. + * + * Deliberately a bad rate next to an ordinary repair, which buys a full point + * of condition per part. Rebuilding what a subsystem is *capable* of is not + * patching it up; it is the expensive thing you do when patching has stopped + * helping. + * + * At 0.12 a crash's permanent cost (~0.24 of the chassis ceiling) takes two + * parts to undo, or about seven missions. + */ +const CEILING_PER_PART = 0.12; + +/** + * Spends parts on raising the ceiling itself, worst subsystem first. + * + * The ceiling used to be a pure one-way ratchet: the only number in the game + * that could move in one direction, with no counterplay at any price. That + * reads less as a design pillar than as a punishment you cannot answer — you + * watch the car get permanently worse and there is nothing to *do* about it. + * + * So decline is now something you can fight rather than only absorb, and what + * makes it still mean something is the exchange rate. This does not touch the + * current level at all: an overhaul raises what the car could be, and it is + * ordinary repair that then fills it in. Early on every part goes to repairs + * and there is never a surplus; it is only once the ceiling is what is actually + * holding you back that parts start piling up with nowhere else to go. + */ +export function overhaul(c: CarCondition, parts: number): { condition: CarCondition; unused: number } { + const ceiling = { ...c.ceiling }; + let remaining = parts; + + for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) { + const worst = [...SUBSYSTEMS] + .filter((p) => ceiling[p] < 1 - OVERHAUL_EPSILON) + .sort((a, b) => ceiling[a] - ceiling[b])[0]; + if (!worst) break; + + const room = 1 - ceiling[worst]; + const needed = room / CEILING_PER_PART; + // Snap when the spend covers the whole gap, rather than landing a + // rounding error short of factory and reporting the car as still rebuildable. + ceiling[worst] = + remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_PART); + remaining -= Math.min(needed, remaining); + } + + // Levels are untouched, and still cannot sit above their ceiling — which they + // now never do, since every ceiling here only went up. + return { condition: { level: { ...c.level }, ceiling }, unused: remaining }; +} + +/** + * Tolerance for "as good as new". Wide enough that accumulated float error + * never leaves a car reporting itself rebuildable when nothing is left to buy. + */ +const OVERHAUL_EPSILON = 1e-6; + +/** True when there is nothing an overhaul could buy: the car is as good as new. */ +export const canOverhaul = (c: CarCondition): boolean => + SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON); + /** Rough single number for the HUD and for deciding when a car is finished. */ export const overallCondition = (c: CarCondition): number => (c.level.engine + c.level.tires + c.level.chassis) / 3; diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index 555fe31..105e01f 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; import { generateWorld } from './world'; -import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car'; +import { + applyWear, + canOverhaul, + deriveHandling, + freshCondition, + overhaul, + repair, + SUBSYSTEMS, + type CarCondition, +} from './car'; describe('world generation', () => { it('is reproducible from a seed', () => { @@ -239,3 +248,81 @@ describe('districts and landmarks', () => { ); }); }); + +describe('overhaul', () => { + /** A car that has been through it: low ceilings, levels patched up to them. */ + const battered = () => { + let c = freshCondition(); + for (let i = 0; i < 6; i++) { + c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 }); + c = repair(c, 99).condition; + } + return c; + }; + + it('is the one thing that can push a ceiling back up', () => { + const c = battered(); + expect(c.ceiling.chassis).toBeLessThan(0.9); + const after = overhaul(c, 3).condition; + expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis); + }); + + it('leaves the level alone — it buys capability, not condition', () => { + const c = battered(); + const after = overhaul(c, 3).condition; + expect(after.level).toEqual(c.level); + // Which is what makes it a *second* spend: the room it opens up still has + // to be filled by ordinary repair. + expect(after.level.chassis).toBeLessThan(after.ceiling.chassis); + }); + + it('works on the worst subsystem first', () => { + const c: CarCondition = { + level: { engine: 0.5, tires: 0.5, chassis: 0.5 }, + ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 }, + }; + const after = overhaul(c, 1).condition; + expect(after.ceiling.tires).toBeGreaterThan(0.5); + expect(after.ceiling.engine).toBe(0.9); + }); + + it('is a worse rate than patching the car up, and deliberately so', () => { + const c: CarCondition = { + level: { engine: 0.5, tires: 0.5, chassis: 0.5 }, + ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 }, + }; + const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 1).condition; + const rebuilt = overhaul(c, 1).condition; + const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5; + const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5; + expect(rebuiltGain).toBeLessThan(repairedGain); + }); + + it('never goes past factory, and hands back what it could not use', () => { + const { condition, unused } = overhaul(battered(), 99); + for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9); + expect(unused).toBeGreaterThan(0); + expect(canOverhaul(condition)).toBe(false); + }); + + it('costs about seven missions to undo one crash', () => { + // The exchange rate is the whole design. Free and the ceiling means + // nothing; unaffordable and it is the ratchet it was before. + const crashed = applyWear(freshCondition(), { + dt: 1 / 60, + distance: 0.4, + throttle: 0, + impactForce: 2.47e6, + }); + const lost = 1 - crashed.ceiling.chassis; + let spent = 0; + let c = crashed; + while (c.ceiling.chassis < 1 - 1e-9 && spent < 50) { + c = overhaul(c, 0.3).condition; // one mission's pay + spent += 0.3; + } + expect(lost).toBeGreaterThan(0.15); + expect(spent / 0.3).toBeGreaterThan(4); + expect(spent / 0.3).toBeLessThan(12); + }); +}); diff --git a/src/ui/board.ts b/src/ui/board.ts index 50d7a2a..ebd8a13 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -42,6 +42,17 @@ function describe(offer: Offer): string { return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`; } +/** + * The workshop line, on every panel a base can show. + * + * Repairs happen by themselves on hand-in; this is the other thing a base is + * for, and it is the only counterplay to a ceiling that otherwise only ever + * falls. It has to be visible wherever you are standing at one, or nobody + * discovers it exists. + */ +const OVERHAUL_FOOTER = + ' · [C] overhaul — spend parts on what she is capable of'; + export function createBoard() { const el = document.createElement('div'); el.id = 'board'; @@ -87,7 +98,7 @@ export function createBoard() { `, ) .join('')} - `; + `; }, /** @@ -106,7 +117,7 @@ export function createBoard() {
Nobody here will take it off your hands, and nobody here will hold the board open while you decide.
- `; + `; }, hide() { diff --git a/src/ui/hud.ts b/src/ui/hud.ts index b61f846..83a9c17 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -170,8 +170,8 @@ export function createHud(seed: number, debug = false) { `[debug] seed ${seed}`, ] : []), - `WASD drive · space handbrake · R respawn · X drop job at a base` + - ` · M sound ${model.muted ? 'off' : 'on'}`, + 'WASD drive · space handbrake · R respawn', + `at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`, 'hold R to reset the campaign', ]; if (model.resetProgress > 0.06) {