diff --git a/src/main.ts b/src/main.ts index 652a8bd..16ad8bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,10 @@ import { generateWorld } from './sim/world'; import { applyWear, canOverhaul, + charity, deriveHandling, freshCondition, + isDerelict, overhaul, repair, surfaceFor, @@ -408,7 +410,21 @@ async function boot() { const finishDeath = () => { kia++; const at = physics.chassis.translation(); - const home = bases.reduce((best, b) => + /* + * The nearest base *your own side holds*, not simply the nearest. + * + * Bases are placed by spreading them across the map, which was fine when + * they were only quest boards — two of the four sit out in frontier ground. + * Once one of them became where you wake up after dying, "nearest" meant + * that dying deep in enemy country put you deeper still: a fresh dent in + * the car, five ambient patrols for company, and a drive home you were in + * no state to make. Dying should set you back, not bury you. + */ + const reachable = bases.filter((b) => { + const ground = controlAt(front, b.x, b.z); + return ground === 'liberated' || ground === 'contested'; + }); + const home = (reachable.length > 0 ? reachable : 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, ); @@ -554,6 +570,18 @@ async function boot() { const base = dead ? null : baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; + // --- The bottom of the barrel --- + // Nobody is left sitting in a wreck with an empty pocket. This is the one + // thing in the loop that pushes back: everything else makes a bad car + // likelier to get worse. It restores nothing permanent — the ceiling is + // untouched — so the decline still stands and only the dead end goes. + if (base && stopped && isDerelict(condition) && quests.funds < 20) { + condition = charity(condition); + say('They will not watch you push it. Patched up, and you owe them.', 7); + audio.ui('accept'); + persistence.checkpoint(elapsed, snapshot()); + } + // --- 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 diff --git a/src/sim/car.ts b/src/sim/car.ts index 8f3de17..a7eb4aa 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -50,8 +50,15 @@ export interface Handling { * the units it is actually about, and keeps the scale of the currency a purely * presentational decision — ¤34 for a supply run reads as a payment, where 0.34 * read as a fraction of something unnamed. + * + * Lowered from 100 once crash damage went up fivefold. A reference head-on + * costs 1.4 points of condition; at the old rate that was ¤140 to put right, + * four missions' pay, for one bad corner — in a car that was by then too slow + * to do those missions well. The *permanent* share is meant to be what a crash + * costs you. Being unable to afford to get back on the road at all is not a + * consequence, it is a dead end. */ -export const FUNDS_PER_CONDITION = 100; +export const FUNDS_PER_CONDITION = 40; export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; @@ -81,8 +88,26 @@ export function deriveHandling(c: CarCondition): Handling { // Bald tyres are the most legible failure: the back end starts to leave. frictionSlip: lerp(1.6, 5, tires), sideFrictionStiffness: lerp(0.5, 1, tires), - // A bent chassis pulls to one side. Sign is stable for a given car. - steeringPull: (1 - chassis) * 0.06, + /* + * A bent chassis pulls to one side. Sign is stable for a given car. + * + * Linear at 0.06 this was the single most destructive number in the game. + * It is an *uncommanded steering input that never lets go*: at 90% chassis + * it walked the car off a fifteen-metre trunk, driving dead straight, in + * under seven seconds. That is the swerve, and it was also most of the + * speed loss, because off the tarmac drive force drops to 55% — measured + * top speed fell 108 to 86 to 75 km/h across the first fifth of the car's + * life, against a hunter that does 76. + * + * Worse, it reads from `chassis`, which is the subsystem a collision hurts + * most. So the thing that most destroys your ability to drive was wired to + * the thing that takes the most damage, and raising crash damage fivefold + * quietly turned one bad corner into a car that cannot be driven straight. + * + * Squared and much smaller, early wear is texture — a car that needs + * watching — and only a genuinely ruined one fights you for the wheel. + */ + steeringPull: (1 - chassis) ** 2 * 0.03, }; } @@ -235,10 +260,13 @@ export function repair(c: CarCondition, funds: number): { condition: CarConditio * patching it up; it is the expensive thing you do when patching has stopped * helping. * - * A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to - * undo, or seven missions' pay. + * Deliberately an absolute number rather than a multiple of the repair rate. + * Repairs got cheaper so that a crash cannot strand you; the *scar* is supposed + * to stay expensive, and tying the two together would have quietly discounted + * the one thing that is meant to hurt. A crash's permanent cost — about 0.42 + * of ceiling across the three subsystems — still runs some ¤350, ten missions. */ -const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION; +const CEILING_PER_FUND = 0.0012; /** * Spends parts on raising the ceiling itself, worst subsystem first. @@ -285,6 +313,41 @@ export function overhaul(c: CarCondition, funds: number): { condition: CarCondit */ const OVERHAUL_EPSILON = 1e-6; +/** + * Condition below which a car is not really a working vehicle, and the level a + * friendly workshop will drag it back to for nothing. + * + * This is the one piece of negative feedback in an otherwise entirely + * self-reinforcing loop. Everything else in the game pushes the same way: a + * crash makes the car slower and harder to hold straight, which makes the next + * crash likelier, which costs money you earn by driving, which you now do + * badly. Reach the bottom with nothing in your pocket and there is no sequence + * of good decisions that gets you out — you are not playing a hard game, you + * are watching one end. + * + * So the insurgency will not let its only driver sit in a wreck. It patches you + * up to something that runs and no further, and it cannot touch the ceiling, so + * the decline is untouched and only the dead end is removed. + */ +export const DERELICT = 0.3; +export const CHARITY_LEVEL = 0.55; + +/** Would a friendly workshop take pity on this car? */ +export const isDerelict = (c: CarCondition): boolean => + overallCondition(c) < DERELICT; + +/** + * Patch a wreck back to something driveable, for free. Ceilings are untouched: + * this buys back none of the decline, only the ability to keep playing. + */ +export function charity(c: CarCondition): CarCondition { + const level = { ...c.level }; + for (const part of SUBSYSTEMS) { + level[part] = Math.min(c.ceiling[part], Math.max(level[part], CHARITY_LEVEL)); + } + return { level, ceiling: { ...c.ceiling } }; +} + /** 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); diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index e6dd26c..03d077b 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -3,6 +3,8 @@ import { generateWorld } from './world'; import { applyWear, canOverhaul, + charity, + isDerelict, FUNDS_PER_CONDITION, deriveHandling, freshCondition, @@ -330,3 +332,101 @@ describe('overhaul', () => { expect(missions).toBeLessThan(20); }); }); + +describe('the car does not fight you for the wheel', () => { + /** + * `steeringPull` is an uncommanded steering input that never lets go, and at + * its original 0.06 linear it was the most destructive number in the game: at + * 90% chassis it walked the car off a fifteen-metre trunk, driving dead + * straight, in under seven seconds. That also cost most of the top speed, + * because off the tarmac drive force drops to 55%. + * + * The numbers below are metres of drift over ten seconds at 25 m/s, from + * `steeringPull` alone. A trunk is 15m wide, so half of it is the budget. + */ + const driftOverTenSeconds = (chassis: number) => { + const pull = deriveHandling({ + level: { engine: 1, tires: 1, chassis }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }).steeringPull; + // Small-angle: lateral ≈ ½·(v²/R)·t², with R = wheelbase/tan(pull). + const speed = 25; + const radius = 2.6 / Math.max(pull, 1e-9); + return 0.5 * ((speed * speed) / radius) * 10 * 10; + }; + + it('holds a lane on a car that is merely worn', () => { + // A car at 90% should need watching, not constant correction. + expect(driftOverTenSeconds(0.9)).toBeLessThan(7.5); + }); + + it('still wanders once it is genuinely bent', () => { + // But a wreck has to be a handful, or damage means nothing. + expect(driftOverTenSeconds(0.2)).toBeGreaterThan(driftOverTenSeconds(0.9) * 20); + }); + + it('gets worse faster than it gets bad, rather than the other way round', () => { + // Squared, not linear: the first fifth of the car's life should barely be + // felt, and the last fifth should be most of it. + const early = driftOverTenSeconds(0.8) - driftOverTenSeconds(1); + const late = driftOverTenSeconds(0.2) - driftOverTenSeconds(0.4); + expect(late).toBeGreaterThan(early * 5); + }); +}); + +describe('there is a way back up', () => { + const wrecked = (): CarCondition => ({ + level: { engine: 0.1, tires: 0.15, chassis: 0.05 }, + ceiling: { engine: 0.7, tires: 0.75, chassis: 0.65 }, + }); + + it('recognises a car that cannot do the job', () => { + expect(isDerelict(wrecked())).toBe(true); + expect(isDerelict(freshCondition())).toBe(false); + }); + + it('patches it back to something that runs', () => { + const after = charity(wrecked()); + expect(isDerelict(after)).toBe(false); + for (const part of SUBSYSTEMS) expect(after.level[part]).toBeGreaterThan(0.5); + }); + + it('buys back none of the decline', () => { + // The whole point: it removes the dead end without touching the ratchet. + const before = wrecked(); + const after = charity(before); + expect(after.ceiling).toEqual(before.ceiling); + }); + + it('never lifts anything above what the car is still capable of', () => { + const nearlyFinished: CarCondition = { + level: { engine: 0.05, tires: 0.05, chassis: 0.05 }, + ceiling: { engine: 0.4, tires: 0.4, chassis: 0.4 }, + }; + const after = charity(nearlyFinished); + for (const part of SUBSYSTEMS) expect(after.level[part]).toBeCloseTo(0.4, 9); + }); + + it('leaves a crash affordable to repair, and its scar expensive', () => { + const crashed = applyWear(freshCondition(), { + dt: 1 / 60, + distance: 0.4, + throttle: 0, + impactForce: 2.47e6, + }); + const MISSION = 34; + const lost = + 3 - (crashed.level.engine + crashed.level.tires + crashed.level.chassis); + const toRepair = (lost * FUNDS_PER_CONDITION) / MISSION; + // A couple of jobs to be driving properly again... + expect(toRepair).toBeLessThan(3); + // ...but the permanent share still costs an evening to undo. + let missions = 0; + let c = crashed; + while (canOverhaul(c) && missions < 200) { + c = overhaul(c, MISSION).condition; + missions++; + } + expect(missions).toBeGreaterThan(6); + }); +});