Calibrate crash damage against forces Rapier actually reports

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 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 07:07:32 +02:00
parent d10033d79e
commit 84ee726a51
2 changed files with 87 additions and 8 deletions

View File

@ -116,22 +116,55 @@ export interface WearInput {
const PERMANENT_SHARE = 0.3; const PERMANENT_SHARE = 0.3;
/** /**
* Returns a new condition. Numbers here are deliberately aggressive so a * Newtons of contact force that count as one unit of "impact".
* 15-minute session shows visible decline tune down once the feel is right. *
* 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 { export function applyWear(c: CarCondition, w: WearInput): CarCondition {
const impact = w.impactForce / 1e5; const impact = w.impactForce / IMPACT_REFERENCE;
const damage: Subsystems = { const damage: Subsystems = {
engine: w.throttle * w.dt * 6e-4 + impact * 0.01, engine: w.throttle * w.dt * 6e-4 + impact * 0.32,
tires: w.distance * 8e-5 + impact * 0.02, tires: w.distance * 8e-5 + impact * 0.5,
chassis: impact * 0.05, chassis: impact * 1.0,
}; };
const level = {} as Subsystems; const level = {} as Subsystems;
const ceiling = {} as Subsystems; const ceiling = {} as Subsystems;
for (const part of SUBSYSTEMS) { for (const part of SUBSYSTEMS) {
ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE); const taken = Math.min(damage[part], MAX_DAMAGE_PER_STEP);
level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]); ceiling[part] = clamp01(c.ceiling[part] - taken * PERMANENT_SHARE);
level[part] = Math.min(clamp01(c.level[part] - taken), ceiling[part]);
} }
return { level, ceiling }; return { level, ceiling };
} }

View File

@ -39,6 +39,52 @@ describe('car condition', () => {
expect(c.level.tires).toBe(0); 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', () => { it('makes a worn car measurably worse to drive', () => {
const fresh = deriveHandling(freshCondition()); const fresh = deriveHandling(freshCondition());
const worn = deriveHandling({ const worn = deriveHandling({