Parts become money, and the debug panel can hand you some

"0.34 parts" read as a fraction of something unnamed. It is a payment,
so it now looks like one: ¤34. The symbol is Unicode's generic currency
sign - it looks like money and is nobody's actual money, which is right
for a place whose banks are somebody else's problem.

The scale moves with it. FUNDS_PER_CONDITION fixes the exchange rate in
one place, so every balance number stays in the unit it is actually
about and how large the currency reads stays a presentational decision.
A supply run pays ¤34, an overhaul runs ¤200 to undo a crash's
permanent cost, and a full rebuild after a bad one is about a dozen
missions.

The debug panel gets ±¤ next to the existing condition levers, so the
workshop and the board can be exercised without first driving eight
missions to afford looking at them.

Save format bumped: quests.parts is now quests.funds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 08:06:57 +02:00
parent 1019c3aec3
commit 6c1132b133
15 changed files with 179 additions and 101 deletions

View File

@ -31,6 +31,7 @@ seeds may be numbers or words.
| `R` | respawn the car — **does not** repair it | | `R` | respawn the car — **does not** repair it |
| `1``3` | accept a mission, when parked at a base | | `1``3` | accept a mission, when parked at a base |
| `X` | give up the mission in hand, when parked at a base | | `X` | give up the mission in hand, when parked at a base |
| `C` | overhaul the car, when parked at a base |
| `M` | sound on/off | | `M` | sound on/off |
| hold `R` | wipe the campaign and start over (5 seconds) | | hold `R` | wipe the campaign and start over (5 seconds) |
@ -254,19 +255,35 @@ sim: crossing a border, a road you have made notorious, a car past saving, or
the front genuinely drifting your way, which is the brief's "the enemy has other the front genuinely drifting your way, which is the brief's "the enemy has other
priorities" said out loud without ever showing a number. priorities" said out loud without ever showing a number.
### Rewards, and why repairs exist ### Rewards, repairs, and the overhaul
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. The currency sign
is `¤` — Unicode's generic one, which looks like money and is nobody's actual
money, for a place whose banks are somebody else's problem. That multiplier is
the phase's tuning dial: if you never take the new target it is too low, and if the phase's tuning dial: if you never take the new target it is too low, and if
you never take the known one it is too high. you never take the known one it is too high.
Payment is in **parts**, which repair the car — the only thing in the game that Payment is money, and money repairs the car. Repairs go to the worst subsystem first and stop at
pushes condition back up. Repairs go to the worst subsystem first and stop at
its **ceiling**, which drops permanently with every bit of damage taken its **ceiling**, which drops permanently with every bit of damage taken
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is (`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
still capable of, but never to what it was. The HUD bars show this directly: `█` still capable of, but never to what it was. The HUD bars show this directly: `█`
is current condition, `·` is what repairs could still reach, `×` is gone. is current condition, `·` is what repairs could still reach, `×` is gone.
Repairs happen automatically on hand-in, and they stop at the ceiling. Pressing
`C` at a base does the other thing: an **overhaul**, which buys the *ceiling*
itself back at 0.12 a point per ¤1, worst subsystem first. It deliberately does
not touch the current level — it raises what the car could be, and ordinary
repair still has to fill it in.
The ceiling used to fall and nothing else: the only number in the game that
moved one way, with no counterplay at any price. That reads less as a design
pillar than as a punishment you cannot answer. Now decline is something you can
fight, and what keeps it meaningful is the exchange rate rather than the
impossibility — putting a car back to factory after one bad crash is about a
dozen missions. Early on there is never a surplus to spend, so the option is
invisible; it only becomes the thing to save for once the ceiling is what is
actually holding you back.
This is what gives the board's reward a reason to matter. Without an economy the This is what gives the board's reward a reason to matter. Without an economy the
choice between a safe route and a risky one has no stakes; with one, the risky choice between a safe route and a risky one has no stakes; with one, the risky
job is what keeps the car alive a while longer. job is what keeps the car alive a while longer.
@ -278,7 +295,7 @@ Measured on the running game, a head-on into a building at 85 km/h comes back
from Rapier as a single contact event of about **2.5e6 N** — twenty-five units from Rapier as a single contact event of about **2.5e6 N** — twenty-five units
of "impact" against the old 1e5 reference. Through the old coefficients that was 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 step, in a game whose 123% of the chassis and 37% of its ceiling, gone, in one step, in a game whose
missions pay about 0.3 parts. One crash ended the campaign, and the campaign had missions pay about ¤30. One crash ended the campaign, and the campaign had
no way to end. no way to end.
Everything is now anchored to that measured figure, and damage is capped per Everything is now anchored to that measured figure, and damage is capped per
@ -288,7 +305,7 @@ crash costs about 15% of the chassis and 5% of its ceiling.
Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean
kilometres took them from new to ruined while a two-kilometre round trip paid kilometres took them from new to ruined while a two-kilometre round trip paid
0.3 parts against 0.24 of condition spent. Driving perfectly barely broke even. ¤30 against 0.24 of condition spent. Driving perfectly barely broke even.
At a quarter of that, a typical mission spends about 0.06, so roughly two clean At a quarter of that, a typical mission spends about 0.06, so roughly two clean
runs pay for one bad crash — enough slack to gamble with, not enough to ignore. runs pay for one bad crash — enough slack to gamble with, not enough to ignore.
@ -305,7 +322,7 @@ front line goes back to the enemy, through the same path a finished mission uses
so the war moves for one reason only. so the war moves for one reason only.
The replacement is a real car — patched to its ceilings, because one that still The replacement is a real car — patched to its ceilings, because one that still
needed the parts you do not have would just kill you again on the way out of the needed money you do not have would just kill you again on the way out of the
yard — and every ceiling is 8% down on the one you wrote off. That is the yard — and every ceiling is 8% down on the one you wrote off. That is the
ratchet applied at the one moment repairs cannot reach. ratchet applied at the one moment repairs cannot reach.
@ -609,14 +626,15 @@ one place where "how broken the car is" turns into "how it drives".
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in - **The car handles like a placeholder.** Suspension, grip, and engine numbers in
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test, `physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
not something tuned by feel. That tuning *is* Phase 0's verification gate. not something tuned by feel. That tuning *is* Phase 0's verification gate.
- **Wear rates are tuned against the parts economy** rather than by feel: a - **Wear rates are tuned against the economy** rather than by feel: a
clean mission spends about a fifth of what it pays, and a hard crash costs clean mission spends about a fifth of what it pays, and a hard crash costs
roughly two clean missions. Those ratios are the dial, not the raw numbers. roughly two clean missions. Those ratios are the dial, not the raw numbers.
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step - **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
rate changes. rate changes.
- **Repairs are automatic on hand-in.** Parts get spent the moment you report - **Repairs are automatic on hand-in.** Money gets spent the moment you report
back. The brief wants repair to be a physical, on-foot act with scavenged back; the overhaul under `C` is the only deliberate spend. The brief wants
parts; that is Phase 6. repair to be a physical, on-foot act with scavenged parts; that is still
deferred.
- **The board's route preview assumes the shortest path.** If you drive a - **The board's route preview assumes the shortest path.** If you drive a
different way, the intel you were shown described a route you did not take. different way, the intel you were shown described a route you did not take.
`findRoute` already accepts a cost function, so a "safest route" preview is a `findRoute` already accepts a cost function, so a "safest route" preview is a

View File

@ -16,6 +16,7 @@ import type { SceneView } from './render/scene';
import type { PhysicsWorld } from './physics/physics'; import type { PhysicsWorld } from './physics/physics';
import type { LoopHandlers } from './core/loop'; import type { LoopHandlers } from './core/loop';
import { STEP } from './core/loop'; import { STEP } from './core/loop';
import { CURRENCY } from './ui/currency';
export interface CaptureOptions { export interface CaptureOptions {
/** Output width in pixels. Height follows 16:9. */ /** Output width in pixels. Height follows 16:9. */
@ -38,6 +39,9 @@ export interface DebugApi {
teleport: (x: number, z: number) => void; teleport: (x: number, z: number) => void;
condition: () => { level: { engine: number; tires: number; chassis: number } }; condition: () => { level: { engine: number; tires: number; chassis: number } };
setCondition: (level: number) => unknown; setCondition: (level: number) => unknown;
/** Reads and writes the money in hand, for trying the workshop out. */
funds: () => number;
setFunds: (amount: number) => number;
} }
/** /**
@ -47,7 +51,10 @@ export interface DebugApi {
* is a thing you want to do *while driving*, not while typing so condition * is a thing you want to do *while driving*, not while typing so condition
* gets buttons. Only ever mounted under `?debug=1`. * gets buttons. Only ever mounted under `?debug=1`.
*/ */
function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: DebugApi): void { function mountPanel(
debug: { nudgeCondition: (by: number) => unknown; nudgeFunds: (by: number) => number },
api: DebugApi,
): void {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.id = 'debug-panel'; panel.id = 'debug-panel';
panel.style.cssText = ` panel.style.cssText = `
@ -58,14 +65,20 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px; border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
`; `;
const readout = document.createElement('span'); const reading = (width: number) => {
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;'; const span = document.createElement('span');
span.style.cssText = `min-width: ${width}px; color: #8fa3b5;`;
return span;
};
const carReadout = reading(74);
const fundsReadout = reading(66);
const refresh = () => { const refresh = () => {
const { engine, tires, chassis } = api.condition().level; const { engine, tires, chassis } = api.condition().level;
readout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`; carReadout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
fundsReadout.textContent = `${CURRENCY}${Math.round(api.funds())}`;
}; };
const button = (label: string, by: number) => { const button = (label: string, apply: () => void) => {
const element = document.createElement('button'); const element = document.createElement('button');
element.textContent = label; element.textContent = label;
element.style.cssText = ` element.style.cssText = `
@ -74,7 +87,7 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
padding: 3px 9px; padding: 3px 9px;
`; `;
element.addEventListener('click', () => { element.addEventListener('click', () => {
debug.nudgeCondition(by); apply();
refresh(); refresh();
// Keep the keyboard on the game, or space starts pressing this button. // Keep the keyboard on the game, or space starts pressing this button.
element.blur(); element.blur();
@ -83,9 +96,14 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
}; };
panel.append(document.createTextNode('debug')); panel.append(document.createTextNode('debug'));
button('', -0.15); button('', () => debug.nudgeCondition(-0.15));
button('+', +0.15); button('+', () => debug.nudgeCondition(+0.15));
panel.append(readout); panel.append(carReadout);
// Money, so the workshop and the board can be exercised without first
// driving eight missions to afford looking at them.
button('−¤', () => debug.nudgeFunds(-50));
button('+¤', () => debug.nudgeFunds(+50));
panel.append(fundsReadout);
document.body.append(panel); document.body.append(panel);
refresh(); refresh();
@ -168,6 +186,12 @@ export function exposeDebug(api: DebugApi): void {
const now = api.condition().level; const now = api.condition().level;
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by); return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
}, },
/** Money in hand. */
funds: api.funds,
setFunds: api.setFunds,
nudgeFunds(by: number) {
return api.setFunds(api.funds() + by);
},
info: api.info, info: api.info,
state: api.state, state: api.state,
three: THREE, three: THREE,

View File

@ -61,6 +61,7 @@ import { createHud } from './ui/hud';
import { createBoard } from './ui/board'; import { createBoard } from './ui/board';
import { createAudio } from './audio/audio'; import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec'; import { CAR, WHEELS } from './carSpec';
import { CURRENCY } from './ui/currency';
/** What the wheel reads while nobody is holding it. */ /** What the wheel reads while nobody is holding it. */
const DEAD_HANDS = { const DEAD_HANDS = {
@ -557,14 +558,14 @@ async function boot() {
if (base && stopped && cmd.overhaul) { if (base && stopped && cmd.overhaul) {
if (!canOverhaul(condition)) { if (!canOverhaul(condition)) {
say('Nothing to rebuild. She is as good as she ever was.', 4); say('Nothing to rebuild. She is as good as she ever was.', 4);
} else if (quests.parts < 0.05) { } else if (quests.funds < 1) {
say('No parts to work with. Bring something back first.', 4); say(`Nothing to pay with. Bring something back first.`, 4);
} else { } else {
const before = condition.ceiling; const before = condition.ceiling;
const done = overhaul(condition, quests.parts); const done = overhaul(condition, quests.funds);
const spent = quests.parts - done.unused; const spent = quests.funds - done.unused;
condition = done.condition; condition = done.condition;
quests.parts = done.unused; quests.funds = done.unused;
const gained = const gained =
condition.ceiling.engine + condition.ceiling.engine +
condition.ceiling.tires + condition.ceiling.tires +
@ -577,7 +578,7 @@ async function boot() {
say('Nothing worth stripping her down for. She is near enough new.', 4); say('Nothing worth stripping her down for. She is near enough new.', 4);
} else { } else {
say( say(
`Stripped and rebuilt. ${spent.toFixed(2)} parts, and she will take ` + `Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
`${(gained * 100).toFixed(0)}% more than she would have.`, `${(gained * 100).toFixed(0)}% more than she would have.`,
7, 7,
); );
@ -690,13 +691,13 @@ async function boot() {
// The line moves because of what the player did. They are not told by // The line moves because of what the player did. They are not told by
// how much — they find out by going back and looking. // how much — they find out by going back and looking.
applyMissionImpact(front, event.impact); applyMissionImpact(front, event.impact);
const outcome = repair(condition, quests.parts); const outcome = repair(condition, quests.funds);
const spent = quests.parts - outcome.unused; const spent = quests.funds - outcome.unused;
condition = outcome.condition; condition = outcome.condition;
quests.parts = outcome.unused; quests.funds = outcome.unused;
say( say(
spent > 0.001 spent > 0.5
? `Handed in. Repairs used ${spent.toFixed(2)} parts.` ? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
: 'Handed in. Nothing left worth repairing.', : 'Handed in. Nothing left worth repairing.',
); );
// Checkpoint: a finished run is the most expensive thing to lose. // Checkpoint: a finished run is the most expensive thing to lose.
@ -723,7 +724,7 @@ async function boot() {
); );
if (found?.kind === 'appeared') say(found.opportunity.hail, 7); if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
else if (found?.kind === 'helped') { else if (found?.kind === 'helped') {
quests.parts += found.opportunity.reward; quests.funds += found.opportunity.reward;
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact); if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
say(found.opportunity.done, 7); say(found.opportunity.done, 7);
persistence.checkpoint(elapsed, snapshot()); persistence.checkpoint(elapsed, snapshot());
@ -1007,7 +1008,7 @@ async function boot() {
completed: quests.completed, completed: quests.completed,
failed: quests.failed, failed: quests.failed,
kia, kia,
parts: quests.parts, funds: quests.funds,
notice: elapsed < noticeUntil ? notice : '', notice: elapsed < noticeUntil ? notice : '',
resetProgress: resetHeld / RESET_HOLD_SECONDS, resetProgress: resetHeld / RESET_HOLD_SECONDS,
muted: audio.muted, muted: audio.muted,
@ -1043,6 +1044,12 @@ async function boot() {
}; };
return condition; return condition;
}, },
funds: () => quests.funds,
/** Set the money in hand. Nothing in the game hands out money like this. */
setFunds(amount: number) {
quests.funds = Math.max(0, amount);
return quests.funds;
},
teleport(x, z) { teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true); physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true); physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
@ -1053,7 +1060,7 @@ async function boot() {
control, control,
segment: currentSegment, segment: currentSegment,
onTarmac, onTarmac,
parts: quests.parts, funds: quests.funds,
completed: quests.completed, completed: quests.completed,
failed: quests.failed, failed: quests.failed,
kia, kia,

View File

@ -42,6 +42,17 @@ export interface Handling {
steeringPull: number; steeringPull: number;
} }
/**
* Money that buys one full point of a subsystem's condition.
*
* The sim thinks in condition, which runs 0..1; the player thinks in what is in
* their pocket. Fixing the exchange rate here lets every balance number stay in
* 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.
*/
export const FUNDS_PER_CONDITION = 100;
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
export const freshCondition = (): CarCondition => ({ export const freshCondition = (): CarCondition => ({
@ -129,7 +140,7 @@ const PERMANENT_SHARE = 0.3;
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25 * 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 * 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 * 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. * ¤30 a mission. One crash ended the campaign.
* *
* At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by * At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by
* the coefficients below, and they are deliberately brutal: a proper head-on * the coefficients below, and they are deliberately brutal: a proper head-on
@ -158,16 +169,16 @@ const MAX_DAMAGE_PER_STEP = 0.85;
* Returns a new condition. * Returns a new condition.
* *
* Baseline wear is deliberately quicker than real life so decline is legible * 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 * within a session, but it has to stay inside what the economy can pay
* for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean * for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean
* kilometres no crashes at all took them from new to ruined, while a * kilometres no crashes at all took them from new to ruined, while a
* two-kilometre round trip paid about 0.3 parts against 0.24 of condition * two-kilometre round trip paid about ¤30 against 0.24 of condition
* spent. Driving perfectly barely broke even and one knock put you permanently * spent. Driving perfectly barely broke even and one knock put you permanently
* behind, which is a decline with no way to arrest it rather than a decision * behind, which is a decline with no way to arrest it rather than a decision
* about how hard to push the car. * about how hard to push the car.
* *
* At a quarter of that a typical mission spends about 0.06 condition against * At a quarter of that a typical mission spends about 0.06 condition against
* ~0.3 parts earned, so roughly two clean runs pay for one bad crash. That is * ~¤30 earned, so roughly two clean runs pay for one bad crash. That is
* the ratio the whole risk/reward loop rests on: enough slack to gamble with, * the ratio the whole risk/reward loop rests on: enough slack to gamble with,
* not enough to ignore. * not enough to ignore.
*/ */
@ -192,12 +203,12 @@ export function applyWear(c: CarCondition, w: WearInput): CarCondition {
} }
/** /**
* Spends parts on the worst subsystem first, never above its ceiling. * Spends money on the worst subsystem first, never above its ceiling.
* Returns the new condition and whatever could not be used. * Returns the new condition and whatever could not be used.
*/ */
export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } { export function repair(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
const level = { ...c.level }; const level = { ...c.level };
let remaining = parts; let remaining = funds / FUNDS_PER_CONDITION;
// Worst-first: a car with one ruined subsystem drives worse than one that is // Worst-first: a car with one ruined subsystem drives worse than one that is
// evenly tired, so that is where a scarce part belongs. // evenly tired, so that is where a scarce part belongs.
@ -213,21 +224,21 @@ export function repair(c: CarCondition, parts: number): { condition: CarConditio
remaining -= spend; remaining -= spend;
} }
return { condition: { level, ceiling: c.ceiling }, unused: remaining }; return { condition: { level, ceiling: c.ceiling }, unused: remaining * FUNDS_PER_CONDITION };
} }
/** /**
* Ceiling bought back per part spent on an overhaul. * Ceiling bought back per unit of currency spent on an overhaul.
* *
* Deliberately a bad rate next to an ordinary repair, which buys a full point * 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 * 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 * patching it up; it is the expensive thing you do when patching has stopped
* helping. * helping.
* *
* At 0.12 a crash's permanent cost (~0.24 of the chassis ceiling) takes two * A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
* parts to undo, or about seven missions. * undo, or seven missions' pay.
*/ */
const CEILING_PER_PART = 0.12; const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
/** /**
* Spends parts on raising the ceiling itself, worst subsystem first. * Spends parts on raising the ceiling itself, worst subsystem first.
@ -244,9 +255,9 @@ const CEILING_PER_PART = 0.12;
* and there is never a surplus; it is only once the ceiling is what is actually * 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. * holding you back that parts start piling up with nowhere else to go.
*/ */
export function overhaul(c: CarCondition, parts: number): { condition: CarCondition; unused: number } { export function overhaul(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
const ceiling = { ...c.ceiling }; const ceiling = { ...c.ceiling };
let remaining = parts; let remaining = funds;
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) { for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
const worst = [...SUBSYSTEMS] const worst = [...SUBSYSTEMS]
@ -255,11 +266,11 @@ export function overhaul(c: CarCondition, parts: number): { condition: CarCondit
if (!worst) break; if (!worst) break;
const room = 1 - ceiling[worst]; const room = 1 - ceiling[worst];
const needed = room / CEILING_PER_PART; const needed = room / CEILING_PER_FUND;
// Snap when the spend covers the whole gap, rather than landing a // Snap when the spend covers the whole gap, rather than landing a
// rounding error short of factory and reporting the car as still rebuildable. // rounding error short of factory and reporting the car as still rebuildable.
ceiling[worst] = ceiling[worst] =
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_PART); remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_FUND);
remaining -= Math.min(needed, remaining); remaining -= Math.min(needed, remaining);
} }

View File

@ -90,7 +90,7 @@ describe('what a death costs', () => {
expect(quests.active).toBeNull(); expect(quests.active).toBeNull();
expect(quests.completed).toBe(0); expect(quests.completed).toBe(0);
expect(quests.failed).toBe(1); expect(quests.failed).toBe(1);
expect(quests.parts).toBe(0); expect(quests.funds).toBe(0);
}); });
it('is a setback, not a campaign reversal', () => { it('is a setback, not a campaign reversal', () => {

View File

@ -19,7 +19,7 @@ export interface Opportunity {
kind: OpportunityKind; kind: OpportunityKind;
x: number; x: number;
z: number; z: number;
/** Parts paid for stopping. */ /** Money paid for stopping. */
reward: number; reward: number;
/** Whether helping shifts the front at all. */ /** Whether helping shifts the front at all. */
impact: number; impact: number;
@ -59,7 +59,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'They can walk from here. They did not ask your name, which was kind of them.', 'They can walk from here. They did not ask your name, which was kind of them.',
'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.', 'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.',
], ],
reward: 0.06, reward: 6,
impact: 3, impact: 3,
}, },
contact: { contact: {
@ -71,7 +71,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'A package changes hands. Neither of you says anything worth repeating.', 'A package changes hands. Neither of you says anything worth repeating.',
'They pass you a name and a road number, then walk off before you can ask.', 'They pass you a name and a road number, then walk off before you can ask.',
], ],
reward: 0.14, reward: 14,
impact: 9, impact: 9,
}, },
wreck: { wreck: {
@ -83,7 +83,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'You strip what will fit in the boot and leave the rest to rust.', 'You strip what will fit in the boot and leave the rest to rust.',
'Two good parts and a lot of scrap. It will do.', 'Two good parts and a lot of scrap. It will do.',
], ],
reward: 0.11, reward: 11,
impact: 0, impact: 0,
}, },
}; };

View File

@ -208,7 +208,7 @@ describe('mission lifecycle', () => {
expect(done?.kind).toBe('completed'); expect(done?.kind).toBe('completed');
expect(state.active).toBeNull(); expect(state.active).toBeNull();
expect(state.completed).toBe(1); expect(state.completed).toBe(1);
expect(state.parts).toBeGreaterThan(0); expect(state.funds).toBeGreaterThan(0);
}); });
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => { it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {

View File

@ -73,7 +73,7 @@ export interface Offer {
novel: boolean; novel: boolean;
route: Route; route: Route;
intel: RouteIntel; intel: RouteIntel;
/** Parts awarded on hand-in. */ /** Money paid on hand-in. */
reward: number; reward: number;
/** /**
* How far this job pushes the front line, in metres. The brief asks for * How far this job pushes the front line, in metres. The brief asks for
@ -97,8 +97,8 @@ export interface QuestState {
completed: number; completed: number;
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */ /** Jobs dropped or lost with the car. Counted separately: they are not runs. */
failed: number; failed: number;
/** Unspent repair parts. */ /** Money in hand, spent on repairs and overhauls. */
parts: number; funds: number;
nextId: number; nextId: number;
} }
@ -106,7 +106,7 @@ export const createQuests = (): QuestState => ({
active: null, active: null,
completed: 0, completed: 0,
failed: 0, failed: 0,
parts: 0, funds: 0,
nextId: 1, nextId: 1,
}); });
@ -138,11 +138,11 @@ const CARGO_FRAGILITY = 1.4e-6;
* known targets are never taken, too high. * known targets are never taken, too high.
*/ */
const NOVELTY_BONUS = 1.8; const NOVELTY_BONUS = 1.8;
const PARTS_PER_KM = 0.1; const PAY_PER_KM = 10;
/** Exported so the novelty premium can be tested on like for like. */ /** Exported so the novelty premium can be tested on like for like. */
export function rewardFor(type: MissionType, route: Route, novel: boolean): number { export function rewardFor(type: MissionType, route: Route, novel: boolean): number {
const base = 0.08 + (route.length / 1000) * PARTS_PER_KM; const base = 8 + (route.length / 1000) * PAY_PER_KM;
return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward; return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward;
} }
@ -232,7 +232,7 @@ export type QuestEvent =
| { kind: 'arrived' } | { kind: 'arrived' }
| { kind: 'worked'; type: MissionType } | { kind: 'worked'; type: MissionType }
| { kind: 'cargoDamaged'; cargo: number } | { kind: 'cargoDamaged'; cargo: number }
| { kind: 'completed'; parts: number; impact: number; cargo: number }; | { kind: 'completed'; paid: number; impact: number; cargo: number };
/** Advances the active mission. Returns whatever just happened, if anything. */ /** Advances the active mission. Returns whatever just happened, if anything. */
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null { export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
@ -281,8 +281,8 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null
const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1); const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1);
state.active = null; state.active = null;
state.completed++; state.completed++;
state.parts += paid; state.funds += paid;
return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo }; return { kind: 'completed', paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
} }
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null; return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;

View File

@ -29,7 +29,7 @@ function played(): Snapshot {
applyMissionImpact(front, 18); applyMissionImpact(front, 18);
quests.completed = 3; quests.completed = 3;
quests.failed = 1; quests.failed = 1;
quests.parts = 0.42; quests.funds = 42;
quests.nextId = 7; quests.nextId = 7;
return { return {
@ -78,7 +78,7 @@ describe('save round trip', () => {
expect(restored.heat.level).toEqual(source.heat.level); expect(restored.heat.level).toEqual(source.heat.level);
expect(restored.quests.completed).toBe(3); expect(restored.quests.completed).toBe(3);
expect(restored.quests.failed).toBe(1); expect(restored.quests.failed).toBe(1);
expect(restored.quests.parts).toBeCloseTo(0.42, 9); expect(restored.quests.funds).toBeCloseTo(42, 9);
// A campaign's history of written-off cars has to outlive the tab, or the // A campaign's history of written-off cars has to outlive the tab, or the
// ratchet the KIA rule exists to drive resets every time you close it. // ratchet the KIA rule exists to drive resets every time you close it.
expect(restored.kia).toBe(2); expect(restored.kia).toBe(2);

View File

@ -16,7 +16,7 @@ import type { Intel } from './intel';
import type { Boundaries, Front } from './regions'; import type { Boundaries, Front } from './regions';
import type { ActiveQuest, QuestState } from './quests'; import type { ActiveQuest, QuestState } from './quests';
export const SAVE_VERSION = 3; export const SAVE_VERSION = 4;
export interface CarSnapshot { export interface CarSnapshot {
position: [number, number, number]; position: [number, number, number];
@ -47,7 +47,7 @@ export interface SaveData {
active: ActiveQuest | null; active: ActiveQuest | null;
completed: number; completed: number;
failed: number; failed: number;
parts: number; funds: number;
nextId: number; nextId: number;
}; };
/** Times the car has been written off underneath the player. */ /** Times the car has been written off underneath the player. */
@ -89,7 +89,7 @@ export function serialise(s: Snapshot): SaveData {
active: s.quests.active, active: s.quests.active,
completed: s.quests.completed, completed: s.quests.completed,
failed: s.quests.failed, failed: s.quests.failed,
parts: s.quests.parts, funds: s.quests.funds,
nextId: s.quests.nextId, nextId: s.quests.nextId,
}, },
kia: s.kia, kia: s.kia,
@ -146,7 +146,7 @@ export function apply(data: SaveData, into: Snapshot): void {
into.quests.active = data.quests.active; into.quests.active = data.quests.active;
into.quests.completed = data.quests.completed; into.quests.completed = data.quests.completed;
into.quests.failed = data.quests.failed; into.quests.failed = data.quests.failed;
into.quests.parts = data.quests.parts; into.quests.funds = data.quests.funds;
into.quests.nextId = data.quests.nextId; into.quests.nextId = data.quests.nextId;
into.kia = data.kia; into.kia = data.kia;
} }

View File

@ -3,6 +3,7 @@ import { generateWorld } from './world';
import { import {
applyWear, applyWear,
canOverhaul, canOverhaul,
FUNDS_PER_CONDITION,
deriveHandling, deriveHandling,
freshCondition, freshCondition,
overhaul, overhaul,
@ -151,30 +152,31 @@ describe('repair', () => {
const c = damaged(); const c = damaged();
expect(c.ceiling.chassis).toBeLessThan(1); expect(c.ceiling.chassis).toBeLessThan(1);
// No amount of parts can undo it. // No amount of parts can undo it.
const restored = repair(c, 99).condition; const restored = repair(c, 9999).condition;
expect(restored.level.chassis).toBeLessThan(1); expect(restored.level.chassis).toBeLessThan(1);
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6); expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
}); });
it('never lifts a subsystem above its ceiling', () => { it('never lifts a subsystem above its ceiling', () => {
const c = damaged(); const c = damaged();
const restored = repair(c, 99).condition; const restored = repair(c, 9999).condition;
for (const part of SUBSYSTEMS) { for (const part of SUBSYSTEMS) {
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9); expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
} }
}); });
it('hands back parts it could not use', () => { it('hands back parts it could not use', () => {
const { unused } = repair(freshCondition(), 5); const { unused } = repair(freshCondition(), 500);
expect(unused).toBeCloseTo(5, 6); expect(unused).toBeCloseTo(500, 6);
}); });
it('spends a scarce part on the worst subsystem', () => { it('spends scarce money on the worst subsystem', () => {
const c: CarCondition = { const c: CarCondition = {
level: { engine: 0.9, tires: 0.2, chassis: 0.8 }, level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
ceiling: { engine: 1, tires: 1, chassis: 1 }, ceiling: { engine: 1, tires: 1, chassis: 1 },
}; };
const restored = repair(c, 0.1).condition; // A tenth of a point of condition, in what the player actually carries.
const restored = repair(c, 0.1 * FUNDS_PER_CONDITION).condition;
expect(restored.level.tires).toBeCloseTo(0.3, 6); expect(restored.level.tires).toBeCloseTo(0.3, 6);
expect(restored.level.engine).toBe(0.9); expect(restored.level.engine).toBe(0.9);
}); });
@ -186,7 +188,7 @@ describe('repair', () => {
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 }); c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
} }
c = repair(c, 99).condition; c = repair(c, 9999).condition;
// Fully repaired every cycle, and still worse off every cycle. // Fully repaired every cycle, and still worse off every cycle.
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6); expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
expect(c.ceiling.chassis).toBeLessThan(previousCeiling); expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
@ -255,7 +257,7 @@ describe('overhaul', () => {
let c = freshCondition(); let c = freshCondition();
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 }); c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
c = repair(c, 99).condition; c = repair(c, 9999).condition;
} }
return c; return c;
}; };
@ -263,13 +265,13 @@ describe('overhaul', () => {
it('is the one thing that can push a ceiling back up', () => { it('is the one thing that can push a ceiling back up', () => {
const c = battered(); const c = battered();
expect(c.ceiling.chassis).toBeLessThan(0.9); expect(c.ceiling.chassis).toBeLessThan(0.9);
const after = overhaul(c, 3).condition; const after = overhaul(c, 300).condition;
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis); expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
}); });
it('leaves the level alone — it buys capability, not condition', () => { it('leaves the level alone — it buys capability, not condition', () => {
const c = battered(); const c = battered();
const after = overhaul(c, 3).condition; const after = overhaul(c, 300).condition;
expect(after.level).toEqual(c.level); expect(after.level).toEqual(c.level);
// Which is what makes it a *second* spend: the room it opens up still has // Which is what makes it a *second* spend: the room it opens up still has
// to be filled by ordinary repair. // to be filled by ordinary repair.
@ -281,7 +283,7 @@ describe('overhaul', () => {
level: { engine: 0.5, tires: 0.5, chassis: 0.5 }, level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 }, ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
}; };
const after = overhaul(c, 1).condition; const after = overhaul(c, 100).condition;
expect(after.ceiling.tires).toBeGreaterThan(0.5); expect(after.ceiling.tires).toBeGreaterThan(0.5);
expect(after.ceiling.engine).toBe(0.9); expect(after.ceiling.engine).toBe(0.9);
}); });
@ -291,21 +293,21 @@ describe('overhaul', () => {
level: { engine: 0.5, tires: 0.5, chassis: 0.5 }, level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
ceiling: { 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 repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 50).condition;
const rebuilt = overhaul(c, 1).condition; const rebuilt = overhaul(c, 50).condition;
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5; 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; const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
expect(rebuiltGain).toBeLessThan(repairedGain); expect(rebuiltGain).toBeLessThan(repairedGain);
}); });
it('never goes past factory, and hands back what it could not use', () => { it('never goes past factory, and hands back what it could not use', () => {
const { condition, unused } = overhaul(battered(), 99); const { condition, unused } = overhaul(battered(), 9999);
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9); for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
expect(unused).toBeGreaterThan(0); expect(unused).toBeGreaterThan(0);
expect(canOverhaul(condition)).toBe(false); expect(canOverhaul(condition)).toBe(false);
}); });
it('costs about seven missions to undo one crash', () => { it('costs a run of missions to undo one crash, not one and not fifty', () => {
// The exchange rate is the whole design. Free and the ceiling means // The exchange rate is the whole design. Free and the ceiling means
// nothing; unaffordable and it is the ratchet it was before. // nothing; unaffordable and it is the ratchet it was before.
const crashed = applyWear(freshCondition(), { const crashed = applyWear(freshCondition(), {
@ -314,15 +316,17 @@ describe('overhaul', () => {
throttle: 0, throttle: 0,
impactForce: 2.47e6, impactForce: 2.47e6,
}); });
const lost = 1 - crashed.ceiling.chassis; const MISSION_PAY = 30;
let spent = 0; let missions = 0;
let c = crashed; let c = crashed;
while (c.ceiling.chassis < 1 - 1e-9 && spent < 50) { while (canOverhaul(c) && missions < 200) {
c = overhaul(c, 0.3).condition; // one mission's pay c = overhaul(c, MISSION_PAY).condition;
spent += 0.3; missions++;
} }
expect(lost).toBeGreaterThan(0.15); expect(1 - crashed.ceiling.chassis).toBeGreaterThan(0.15);
expect(spent / 0.3).toBeGreaterThan(4); // Putting the whole car back to factory after one bad crash: an evening's
expect(spent / 0.3).toBeLessThan(12); // work, not a weekend's and not a single job.
expect(missions).toBeGreaterThan(6);
expect(missions).toBeLessThan(20);
}); });
}); });

View File

@ -82,7 +82,7 @@ function runMission(type: MissionType, impactPerStep = 0) {
speed: 0, speed: 0,
impactForce: 0, impactForce: 0,
}); });
return { done, cargo, parts: state.parts }; return { done, cargo, paid: state.funds };
} }
describe('carrying cargo home', () => { describe('carrying cargo home', () => {
@ -96,13 +96,13 @@ describe('carrying cargo home', () => {
const clean = runMission('retrieval'); const clean = runMission('retrieval');
const rough = runMission('retrieval', 4e4); const rough = runMission('retrieval', 4e4);
expect(rough.cargo).toBeLessThan(clean.cargo); expect(rough.cargo).toBeLessThan(clean.cargo);
expect(rough.parts).toBeLessThan(clean.parts); expect(rough.paid).toBeLessThan(clean.paid);
}); });
it('leaves other mission types alone, however hard you crash', () => { it('leaves other mission types alone, however hard you crash', () => {
const rough = runMission('supply', 8e4); const rough = runMission('supply', 8e4);
expect(rough.cargo).toBe(1); expect(rough.cargo).toBe(1);
expect(rough.parts).toBeCloseTo(runMission('supply').parts, 9); expect(rough.paid).toBeCloseTo(runMission('supply').paid, 9);
}); });
}); });

View File

@ -1,6 +1,7 @@
import type { Base } from '../sim/bases'; import type { Base } from '../sim/bases';
import type { HeatLevel } from '../sim/heat'; import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests'; import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
import { money } from './currency';
/** /**
* What each job actually asks of you, in the words the people asking would use. * What each job actually asks of you, in the words the people asking would use.
@ -51,7 +52,7 @@ function describe(offer: Offer): string {
* discovers it exists. * discovers it exists.
*/ */
const OVERHAUL_FOOTER = const OVERHAUL_FOOTER =
' · <span class="key">[C]</span> overhaul — spend parts on what she is capable of'; ' · <span class="key">[C]</span> overhaul — spend on what she is capable of';
export function createBoard() { export function createBoard() {
const el = document.createElement('div'); const el = document.createElement('div');
@ -92,7 +93,7 @@ export function createBoard() {
${MISSION_SHAPE[offer.type].label} ${MISSION_SHAPE[offer.type].label}
${offer.novel ? '<span class="novel">new target</span>' : 'known target'} ${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
· ${(offer.route.length / 1000).toFixed(1)} km · ${(offer.route.length / 1000).toFixed(1)} km
· ${offer.reward.toFixed(2)} parts · ${money(offer.reward)}
<div class="meta">${BRIEFING[offer.type]}</div> <div class="meta">${BRIEFING[offer.type]}</div>
<div class="meta">${describe(offer)}</div> <div class="meta">${describe(offer)}</div>
</li>`, </li>`,

12
src/ui/currency.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* What the insurgency pays in.
*
* `¤` is Unicode's generic currency sign: it looks like money and is not any
* particular country's money, which is exactly right for a place whose banks
* are somebody else's problem. Kept here rather than inlined so the whole game
* agrees on it, and so it is one edit if it ever wants a name.
*/
export const CURRENCY = '¤';
/** Money as the player reads it. Whole units — nobody counts small change here. */
export const money = (amount: number): string => `${CURRENCY}${Math.round(amount)}`;

View File

@ -1,4 +1,5 @@
import { SUBSYSTEMS, type CarCondition } from '../sim/car'; import { SUBSYSTEMS, type CarCondition } from '../sim/car';
import { money } from './currency';
import type { HeatLevel } from '../sim/heat'; import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType } from '../sim/quests'; import { MISSION_SHAPE, type MissionType } from '../sim/quests';
import type { Control } from '../sim/regions'; import type { Control } from '../sim/regions';
@ -54,7 +55,7 @@ export interface HudModel {
failed: number; failed: number;
/** Cars written off underneath the player. */ /** Cars written off underneath the player. */
kia: number; kia: number;
parts: number; funds: number;
notice: string; notice: string;
/** 0..1 toward wiping the campaign, while R is held. */ /** 0..1 toward wiping the campaign, while R is held. */
resetProgress: number; resetProgress: number;
@ -141,7 +142,7 @@ export function createHud(seed: number, debug = false) {
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` + `${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
`${(condition.level[part] * 100).toFixed(0)}%`, `${(condition.level[part] * 100).toFixed(0)}%`,
), ),
`parts ${model.parts.toFixed(2)} runs ${model.completed}` + `funds ${money(model.funds).padEnd(8)} runs ${model.completed}` +
(model.failed > 0 ? ` lost ${model.failed}` : '') + (model.failed > 0 ? ` lost ${model.failed}` : '') +
// Cars are counted, not lives. There is no stock of them to run out // Cars are counted, not lives. There is no stock of them to run out
// of; the number is there so a long campaign reads as a history. // of; the number is there so a long campaign reads as a history.