Stop the loop eating itself, ahead of the garage

Diagnosed from a complaint that the car swerves, goes too slow, cannot
outrun anything, and that dying just restarts the same decline. The
constants below are not the real answer - the loop needs restructuring,
and a garage of cars is coming - but each of these is a defect on its
own terms and any redesign still wants them fixed.

steeringPull was the worst of them. It is an uncommanded steering input
that never lets go, and at 0.06 linear it walked the car off a
fifteen-metre trunk, driving dead straight, in under seven seconds at
90% chassis. That is the swerve, and it was also most of the speed
loss, because off the tarmac drive force drops to 55%. It reads from
`chassis`, the subsystem a collision hurts most - so raising crash
damage fivefold, which I did on request without checking what depended
on it, quietly turned one bad corner into a car that cannot be driven
straight. Squared and much smaller, early wear is texture and only a
genuinely bent car fights you for the wheel. Same test: on the road at
90%, and holding for 13 seconds at 80% instead of six.

Dying sent you to the nearest base, and two of the four sit out in
frontier ground - so dying deep put you deeper, in a worse car, with
five ambient patrols for company. Now the nearest base on ground your
own side holds.

Repairs were 100 funds a condition point, so one crash cost 1.4 points,
¤140, four missions - in a car by then too slow to do them well. The
permanent share is what a crash is supposed to cost; being unable to
afford to get back on the road at all is a dead end, not a
consequence. Repairs are 2.5x cheaper and the scar is priced
separately so it stays expensive.

And a floor: a derelict car at a base with an empty pocket gets patched
up for nothing. It buys back no ceiling, so the decline is untouched -
it only removes the state where no sequence of good decisions gets you
out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 20:56:29 +02:00
parent cf5d55bdf0
commit 47e9e8bfdb
3 changed files with 198 additions and 7 deletions

View File

@ -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

View File

@ -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);

View File

@ -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);
});
});