Overhaul: something you can do about the ceiling
The ceiling was the only number in the game that moved one way, with no counterplay at any price. In practice that reads less as a design pillar than as a punishment you cannot answer - you watch the car get permanently worse and there is nothing to *do* about it. Press C at any base to strip and rebuild, worst subsystem first. It buys ceiling and deliberately nothing else: an overhaul raises what the car could be, and ordinary repair still has to fill it in. Early on every part goes to repairs and there is never a surplus, so this is invisible; it only becomes the thing to spend on once the ceiling is what is actually holding you back. The rate is the design, not the impossibility. 0.12 of ceiling per part, against a full point of condition per part for a repair, so undoing one crash's permanent cost runs about seven missions. Decline is now something you can fight rather than only absorb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
764ea12a3f
commit
d9cdca70d5
@ -11,6 +11,8 @@ export interface DriverInput {
|
||||
toggleMute: boolean;
|
||||
/** True on the frame X was pressed: give up the job in hand. Consumed on read. */
|
||||
abandon: boolean;
|
||||
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
|
||||
overhaul: boolean;
|
||||
}
|
||||
|
||||
const KEYS = {
|
||||
@ -22,6 +24,7 @@ const KEYS = {
|
||||
respawn: ['KeyR'],
|
||||
mute: ['KeyM'],
|
||||
abandon: ['KeyX'],
|
||||
overhaul: ['KeyC'],
|
||||
} as const;
|
||||
|
||||
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
||||
@ -38,6 +41,7 @@ export function createInput(): {
|
||||
let pendingSelect: number | null = null;
|
||||
let pendingMute = false;
|
||||
let pendingAbandon = false;
|
||||
let pendingOverhaul = false;
|
||||
/** Called on the first real interaction, to satisfy autoplay policy. */
|
||||
let onFirstGesture: (() => void) | null = null;
|
||||
|
||||
@ -49,6 +53,7 @@ export function createInput(): {
|
||||
if (!down.has(e.code)) {
|
||||
if (e.code === 'KeyM') pendingMute = true;
|
||||
if (e.code === 'KeyX') pendingAbandon = true;
|
||||
if (e.code === 'KeyC') pendingOverhaul = true;
|
||||
const index = SELECT_KEYS.indexOf(e.code);
|
||||
if (index >= 0) pendingSelect = index + 1;
|
||||
}
|
||||
@ -63,6 +68,7 @@ export function createInput(): {
|
||||
pendingSelect = null;
|
||||
pendingMute = false;
|
||||
pendingAbandon = false;
|
||||
pendingOverhaul = false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onDown);
|
||||
@ -74,9 +80,11 @@ export function createInput(): {
|
||||
const select = pendingSelect;
|
||||
const toggleMute = pendingMute;
|
||||
const abandon = pendingAbandon;
|
||||
const overhaul = pendingOverhaul;
|
||||
pendingSelect = null;
|
||||
pendingMute = false;
|
||||
pendingAbandon = false;
|
||||
pendingOverhaul = false;
|
||||
return {
|
||||
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
||||
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
||||
@ -85,6 +93,7 @@ export function createInput(): {
|
||||
select,
|
||||
toggleMute,
|
||||
abandon,
|
||||
overhaul,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
48
src/main.ts
48
src/main.ts
@ -1,6 +1,14 @@
|
||||
import * as THREE from 'three';
|
||||
import { generateWorld } from './sim/world';
|
||||
import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
overhaul,
|
||||
repair,
|
||||
surfaceFor,
|
||||
} from './sim/car';
|
||||
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
|
||||
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
|
||||
import { baseAt, placeBases } from './sim/bases';
|
||||
@ -465,6 +473,44 @@ async function boot() {
|
||||
const base = baseAt(bases, at.x, at.z);
|
||||
const stopped = Math.abs(speed) < 2.5;
|
||||
|
||||
// --- 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
|
||||
// ordinary repair — this rebuilds what the car *could* be, which is the
|
||||
// expensive thing you do once patching it up has stopped helping.
|
||||
if (base && stopped && cmd.overhaul) {
|
||||
if (!canOverhaul(condition)) {
|
||||
say('Nothing to rebuild. She is as good as she ever was.', 4);
|
||||
} else if (quests.parts < 0.05) {
|
||||
say('No parts to work with. Bring something back first.', 4);
|
||||
} else {
|
||||
const before = condition.ceiling;
|
||||
const done = overhaul(condition, quests.parts);
|
||||
const spent = quests.parts - done.unused;
|
||||
condition = done.condition;
|
||||
quests.parts = done.unused;
|
||||
const gained =
|
||||
condition.ceiling.engine +
|
||||
condition.ceiling.tires +
|
||||
condition.ceiling.chassis -
|
||||
(before.engine + before.tires + before.chassis);
|
||||
// A car that has turned a wheel is never *exactly* at factory, so the
|
||||
// sim will happily sell you a hundredth of a percent. Judge by what
|
||||
// the player can actually see rather than by what is left to buy.
|
||||
if (gained < 0.005) {
|
||||
say('Nothing worth stripping her down for. She is near enough new.', 4);
|
||||
} else {
|
||||
say(
|
||||
`Stripped and rebuilt. ${spent.toFixed(2)} parts, and she will take ` +
|
||||
`${(gained * 100).toFixed(0)}% more than she would have.`,
|
||||
7,
|
||||
);
|
||||
audio.ui('accept');
|
||||
persistence.checkpoint(elapsed, snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Quest board: only when parked at a base with nothing in hand ---
|
||||
if (base && stopped && !quests.active) {
|
||||
if (openBase !== base.nodeId) {
|
||||
|
||||
@ -21,6 +21,7 @@ const IDLE: DriverInput = {
|
||||
select: null,
|
||||
toggleMute: false,
|
||||
abandon: false,
|
||||
overhaul: false,
|
||||
};
|
||||
|
||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Car condition and what it does to handling. Pure — no engine imports.
|
||||
*
|
||||
* Design pillar this serves: "Decline, not reset." Repairs exist, but every
|
||||
* subsystem carries a *ceiling* that only ever falls. Patching a car brings it
|
||||
* back up to what it is still capable of, which is never quite what it was.
|
||||
* Design pillar this serves: "Decline, not reset." Every subsystem carries a
|
||||
* *ceiling*, and ordinary repair brings a car back up to what it is still
|
||||
* capable of, which is never quite what it was.
|
||||
*
|
||||
* The ceiling used to fall and nothing else — the only number in the game that
|
||||
* moved one way, with no counterplay at any price. In practice that read less
|
||||
* as a pillar than as a punishment you could not answer. `overhaul` is the
|
||||
* answer: decline is now something you can fight rather than only absorb, and
|
||||
* what keeps it meaningful is the exchange rate rather than the impossibility.
|
||||
*/
|
||||
|
||||
export interface Subsystems {
|
||||
@ -210,6 +216,68 @@ export function repair(c: CarCondition, parts: number): { condition: CarConditio
|
||||
return { condition: { level, ceiling: c.ceiling }, unused: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling bought back per part spent on an overhaul.
|
||||
*
|
||||
* Deliberately a bad rate next to an ordinary repair, which buys a full point
|
||||
* of condition per part. Rebuilding what a subsystem is *capable* of is not
|
||||
* patching it up; it is the expensive thing you do when patching has stopped
|
||||
* helping.
|
||||
*
|
||||
* At 0.12 a crash's permanent cost (~0.24 of the chassis ceiling) takes two
|
||||
* parts to undo, or about seven missions.
|
||||
*/
|
||||
const CEILING_PER_PART = 0.12;
|
||||
|
||||
/**
|
||||
* Spends parts on raising the ceiling itself, worst subsystem first.
|
||||
*
|
||||
* The ceiling used to be a pure one-way ratchet: the only number in the game
|
||||
* that could move in one direction, with no counterplay at any price. That
|
||||
* reads less as a design pillar than as a punishment you cannot answer — you
|
||||
* watch the car get permanently worse and there is nothing to *do* about it.
|
||||
*
|
||||
* So decline is now something you can fight rather than only absorb, and what
|
||||
* makes it still mean something is the exchange rate. This does not touch the
|
||||
* current level at all: an overhaul raises what the car could be, and it is
|
||||
* ordinary repair that then fills it in. Early on every part goes to repairs
|
||||
* and there is never a surplus; it is only once the ceiling is what is actually
|
||||
* holding you back that parts start piling up with nowhere else to go.
|
||||
*/
|
||||
export function overhaul(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
|
||||
const ceiling = { ...c.ceiling };
|
||||
let remaining = parts;
|
||||
|
||||
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
|
||||
const worst = [...SUBSYSTEMS]
|
||||
.filter((p) => ceiling[p] < 1 - OVERHAUL_EPSILON)
|
||||
.sort((a, b) => ceiling[a] - ceiling[b])[0];
|
||||
if (!worst) break;
|
||||
|
||||
const room = 1 - ceiling[worst];
|
||||
const needed = room / CEILING_PER_PART;
|
||||
// Snap when the spend covers the whole gap, rather than landing a
|
||||
// rounding error short of factory and reporting the car as still rebuildable.
|
||||
ceiling[worst] =
|
||||
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_PART);
|
||||
remaining -= Math.min(needed, remaining);
|
||||
}
|
||||
|
||||
// Levels are untouched, and still cannot sit above their ceiling — which they
|
||||
// now never do, since every ceiling here only went up.
|
||||
return { condition: { level: { ...c.level }, ceiling }, unused: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerance for "as good as new". Wide enough that accumulated float error
|
||||
* never leaves a car reporting itself rebuildable when nothing is left to buy.
|
||||
*/
|
||||
const OVERHAUL_EPSILON = 1e-6;
|
||||
|
||||
/** 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);
|
||||
|
||||
/** Rough single number for the HUD and for deciding when a car is finished. */
|
||||
export const overallCondition = (c: CarCondition): number =>
|
||||
(c.level.engine + c.level.tires + c.level.chassis) / 3;
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateWorld } from './world';
|
||||
import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
overhaul,
|
||||
repair,
|
||||
SUBSYSTEMS,
|
||||
type CarCondition,
|
||||
} from './car';
|
||||
|
||||
describe('world generation', () => {
|
||||
it('is reproducible from a seed', () => {
|
||||
@ -239,3 +248,81 @@ describe('districts and landmarks', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overhaul', () => {
|
||||
/** A car that has been through it: low ceilings, levels patched up to them. */
|
||||
const battered = () => {
|
||||
let c = freshCondition();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
|
||||
c = repair(c, 99).condition;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
it('is the one thing that can push a ceiling back up', () => {
|
||||
const c = battered();
|
||||
expect(c.ceiling.chassis).toBeLessThan(0.9);
|
||||
const after = overhaul(c, 3).condition;
|
||||
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
|
||||
});
|
||||
|
||||
it('leaves the level alone — it buys capability, not condition', () => {
|
||||
const c = battered();
|
||||
const after = overhaul(c, 3).condition;
|
||||
expect(after.level).toEqual(c.level);
|
||||
// Which is what makes it a *second* spend: the room it opens up still has
|
||||
// to be filled by ordinary repair.
|
||||
expect(after.level.chassis).toBeLessThan(after.ceiling.chassis);
|
||||
});
|
||||
|
||||
it('works on the worst subsystem first', () => {
|
||||
const c: CarCondition = {
|
||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
|
||||
};
|
||||
const after = overhaul(c, 1).condition;
|
||||
expect(after.ceiling.tires).toBeGreaterThan(0.5);
|
||||
expect(after.ceiling.engine).toBe(0.9);
|
||||
});
|
||||
|
||||
it('is a worse rate than patching the car up, and deliberately so', () => {
|
||||
const c: CarCondition = {
|
||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
};
|
||||
const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 1).condition;
|
||||
const rebuilt = overhaul(c, 1).condition;
|
||||
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5;
|
||||
const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
|
||||
expect(rebuiltGain).toBeLessThan(repairedGain);
|
||||
});
|
||||
|
||||
it('never goes past factory, and hands back what it could not use', () => {
|
||||
const { condition, unused } = overhaul(battered(), 99);
|
||||
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
|
||||
expect(unused).toBeGreaterThan(0);
|
||||
expect(canOverhaul(condition)).toBe(false);
|
||||
});
|
||||
|
||||
it('costs about seven missions to undo one crash', () => {
|
||||
// The exchange rate is the whole design. Free and the ceiling means
|
||||
// nothing; unaffordable and it is the ratchet it was before.
|
||||
const crashed = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: 2.47e6,
|
||||
});
|
||||
const lost = 1 - crashed.ceiling.chassis;
|
||||
let spent = 0;
|
||||
let c = crashed;
|
||||
while (c.ceiling.chassis < 1 - 1e-9 && spent < 50) {
|
||||
c = overhaul(c, 0.3).condition; // one mission's pay
|
||||
spent += 0.3;
|
||||
}
|
||||
expect(lost).toBeGreaterThan(0.15);
|
||||
expect(spent / 0.3).toBeGreaterThan(4);
|
||||
expect(spent / 0.3).toBeLessThan(12);
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,6 +42,17 @@ function describe(offer: Offer): string {
|
||||
return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workshop line, on every panel a base can show.
|
||||
*
|
||||
* Repairs happen by themselves on hand-in; this is the other thing a base is
|
||||
* for, and it is the only counterplay to a ceiling that otherwise only ever
|
||||
* falls. It has to be visible wherever you are standing at one, or nobody
|
||||
* discovers it exists.
|
||||
*/
|
||||
const OVERHAUL_FOOTER =
|
||||
' · <span class="key">[C]</span> overhaul — spend parts on what she is capable of';
|
||||
|
||||
export function createBoard() {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'board';
|
||||
@ -87,7 +98,7 @@ export function createBoard() {
|
||||
</li>`,
|
||||
)
|
||||
.join('')}</ol>
|
||||
<footer>press 1–${offers.length} to accept</footer>`;
|
||||
<footer>press 1–${offers.length} to accept${OVERHAUL_FOOTER}</footer>`;
|
||||
},
|
||||
|
||||
/**
|
||||
@ -106,7 +117,7 @@ export function createBoard() {
|
||||
<div class="meta">Nobody here will take it off your hands, and nobody
|
||||
here will hold the board open while you decide.</div>
|
||||
</li></ol>
|
||||
<footer><span class="key">[X]</span> give it up — we lose ground for it</footer>`;
|
||||
<footer><span class="key">[X]</span> give it up — we lose ground for it${OVERHAUL_FOOTER}</footer>`;
|
||||
},
|
||||
|
||||
hide() {
|
||||
|
||||
@ -170,8 +170,8 @@ export function createHud(seed: number, debug = false) {
|
||||
`[debug] seed ${seed}`,
|
||||
]
|
||||
: []),
|
||||
`WASD drive · space handbrake · R respawn · X drop job at a base` +
|
||||
` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
'WASD drive · space handbrake · R respawn',
|
||||
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
'hold R to reset the campaign',
|
||||
];
|
||||
if (model.resetProgress > 0.06) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user