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({