From 84ee726a51554ce1c53e475ac901c3eb0e9b4b38 Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 07:07:32 +0200 Subject: [PATCH] Calibrate crash damage against forces Rapier actually reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The impact coefficients in applyWear were written against an imagined scale. Measured on the running game, a head-on into a building at 85 km/h comes back as a single contact event of ~2.5e6 N — 25 units of "impact" against the old 1e5 reference. Through the old coefficients that was 123% of the chassis and 37% of its ceiling, gone, in one hit, in a game whose missions pay about 0.3 parts each. One crash ended the campaign, and the campaign had no way to end. Anchor the reference to the measured figure instead, and cap what any single step can take: a crash is a pile of contacts across several steps plus whatever the car scrapes on the way to a standstill, and that sum has no natural bound. The same 14-second flat-out run into a building now costs chassis 100 -> 65% and its ceiling 100 -> 89%. Expensive, survivable, and still the worst thing that can happen to the car. Co-Authored-By: Claude Opus 5 --- src/sim/car.ts | 49 +++++++++++++++++++++++++++++++++++++-------- src/sim/sim.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/sim/car.ts b/src/sim/car.ts index 1ac9c21..610d9d3 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -116,22 +116,55 @@ export interface WearInput { const PERMANENT_SHARE = 0.3; /** - * Returns a new condition. Numbers here are deliberately aggressive so a - * 15-minute session shows visible decline — tune down once the feel is right. + * Newtons of contact force that count as one unit of "impact". + * + * Measured, not guessed. Rapier reports contact-force magnitudes far larger + * than the first pass here assumed: a head-on into a building at 85 km/h comes + * back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25 + * units of impact, which through the coefficients below wrote off the chassis — + * and 37% of its ceiling — in one hit, on a car whose whole economy pays about + * 0.3 parts a mission. One crash ended the campaign. + * + * At 1.6e7 the same crash reads as 0.156 impact: about 15% of the chassis and a + * permanent 4.7% off its ceiling. Expensive, survivable, and still the most + * costly thing you can do to the car. + */ +const IMPACT_REFERENCE = 1.6e7; + +/** + * Most damage any one subsystem can take in a single step. + * + * A crash is not one contact. It is a pile of them across a handful of steps, + * plus whatever the car scrapes along on the way to a standstill, and the sum + * has no natural bound. Without a cap the tail of a bad landing is worth more + * than the landing. This is what stops a single frame from writing off a part. + * + * Set well above the ordinary crash so it stays a backstop rather than the + * number that actually decides what a collision costs. + */ +const MAX_DAMAGE_PER_STEP = 0.25; + +/** + * Returns a new condition. + * + * Baseline wear is deliberately quicker than real life so decline is legible + * within a session, but it has to stay inside what the parts economy can pay + * for — see the reasoning on the two constants above. */ export function applyWear(c: CarCondition, w: WearInput): CarCondition { - const impact = w.impactForce / 1e5; + const impact = w.impactForce / IMPACT_REFERENCE; const damage: Subsystems = { - engine: w.throttle * w.dt * 6e-4 + impact * 0.01, - tires: w.distance * 8e-5 + impact * 0.02, - chassis: impact * 0.05, + engine: w.throttle * w.dt * 6e-4 + impact * 0.32, + tires: w.distance * 8e-5 + impact * 0.5, + chassis: impact * 1.0, }; const level = {} as Subsystems; const ceiling = {} as Subsystems; for (const part of SUBSYSTEMS) { - ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE); - level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]); + const taken = Math.min(damage[part], MAX_DAMAGE_PER_STEP); + ceiling[part] = clamp01(c.ceiling[part] - taken * PERMANENT_SHARE); + level[part] = Math.min(clamp01(c.level[part] - taken), ceiling[part]); } return { level, ceiling }; } diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index fcab6de..f27238b 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -39,6 +39,52 @@ describe('car condition', () => { expect(c.level.tires).toBe(0); }); + /** + * The force magnitude Rapier actually reports for a head-on into a building + * at 85 km/h, read off the running game. Every number below is anchored to it + * rather than to a guess about what a collision "should" be worth. + */ + const HARD_CRASH = 2.47e6; + + it('leaves a car driveable after one hard crash', () => { + const c = applyWear(freshCondition(), { + dt: 1 / 60, + distance: 0.4, + throttle: 0, + impactForce: HARD_CRASH, + }); + // Expensive — the worst single thing that can happen to the car — but the + // campaign continues. The first pass wrote off the chassis in this one step. + expect(c.level.chassis).toBeGreaterThan(0.7); + expect(c.level.chassis).toBeLessThan(0.95); + expect(c.ceiling.chassis).toBeGreaterThan(0.9); + }); + + it('caps what a single step can take, however violent the contact', () => { + // A pileup reports contact forces without any natural bound; without a cap + // one frame of a bad landing is worth more than the crash that caused it. + const c = applyWear(freshCondition(), { + dt: 1 / 60, + distance: 0.4, + throttle: 1, + impactForce: 1e9, + }); + for (const part of SUBSYSTEMS) { + expect(c.level[part]).toBeGreaterThanOrEqual(0.74); + } + }); + + it('takes a sustained battering to write the car off', () => { + let c = freshCondition(); + let crashes = 0; + while (c.level.chassis > 0 && crashes < 100) { + c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: HARD_CRASH }); + crashes++; + } + // Enough hard crashes to be a story about the driver, not one bad corner. + expect(crashes).toBeGreaterThan(5); + }); + it('makes a worn car measurably worse to drive', () => { const fresh = deriveHandling(freshCondition()); const worn = deriveHandling({