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 |
| `1``3` | accept a mission, 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 |
| 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
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
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
pushes condition back up. Repairs go to the worst subsystem first and stop at
Payment is money, and money repairs the car. Repairs go to the worst subsystem first and stop at
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
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.
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
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.
@ -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
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
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.
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
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
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.
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
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
`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.
- **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
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
rate changes.
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
back. The brief wants repair to be a physical, on-foot act with scavenged
parts; that is Phase 6.
- **Repairs are automatic on hand-in.** Money gets spent the moment you report
back; the overhaul under `C` is the only deliberate spend. The brief wants
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
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

View File

@ -16,6 +16,7 @@ import type { SceneView } from './render/scene';
import type { PhysicsWorld } from './physics/physics';
import type { LoopHandlers } from './core/loop';
import { STEP } from './core/loop';
import { CURRENCY } from './ui/currency';
export interface CaptureOptions {
/** Output width in pixels. Height follows 16:9. */
@ -38,6 +39,9 @@ export interface DebugApi {
teleport: (x: number, z: number) => void;
condition: () => { level: { engine: number; tires: number; chassis: number } };
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
* 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');
panel.id = 'debug-panel';
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;
`;
const readout = document.createElement('span');
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;';
const reading = (width: number) => {
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 { 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');
element.textContent = label;
element.style.cssText = `
@ -74,7 +87,7 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
padding: 3px 9px;
`;
element.addEventListener('click', () => {
debug.nudgeCondition(by);
apply();
refresh();
// Keep the keyboard on the game, or space starts pressing this button.
element.blur();
@ -83,9 +96,14 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
};
panel.append(document.createTextNode('debug'));
button('', -0.15);
button('+', +0.15);
panel.append(readout);
button('', () => debug.nudgeCondition(-0.15));
button('+', () => debug.nudgeCondition(+0.15));
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);
refresh();
@ -168,6 +186,12 @@ export function exposeDebug(api: DebugApi): void {
const now = api.condition().level;
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,
state: api.state,
three: THREE,

View File

@ -61,6 +61,7 @@ import { createHud } from './ui/hud';
import { createBoard } from './ui/board';
import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec';
import { CURRENCY } from './ui/currency';
/** What the wheel reads while nobody is holding it. */
const DEAD_HANDS = {
@ -557,14 +558,14 @@ async function boot() {
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 if (quests.funds < 1) {
say(`Nothing to pay with. Bring something back first.`, 4);
} else {
const before = condition.ceiling;
const done = overhaul(condition, quests.parts);
const spent = quests.parts - done.unused;
const done = overhaul(condition, quests.funds);
const spent = quests.funds - done.unused;
condition = done.condition;
quests.parts = done.unused;
quests.funds = done.unused;
const gained =
condition.ceiling.engine +
condition.ceiling.tires +
@ -577,7 +578,7 @@ async function boot() {
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 ` +
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
`${(gained * 100).toFixed(0)}% more than she would have.`,
7,
);
@ -690,13 +691,13 @@ async function boot() {
// The line moves because of what the player did. They are not told by
// how much — they find out by going back and looking.
applyMissionImpact(front, event.impact);
const outcome = repair(condition, quests.parts);
const spent = quests.parts - outcome.unused;
const outcome = repair(condition, quests.funds);
const spent = quests.funds - outcome.unused;
condition = outcome.condition;
quests.parts = outcome.unused;
quests.funds = outcome.unused;
say(
spent > 0.001
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
spent > 0.5
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
: 'Handed in. Nothing left worth repairing.',
);
// 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);
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);
say(found.opportunity.done, 7);
persistence.checkpoint(elapsed, snapshot());
@ -1007,7 +1008,7 @@ async function boot() {
completed: quests.completed,
failed: quests.failed,
kia,
parts: quests.parts,
funds: quests.funds,
notice: elapsed < noticeUntil ? notice : '',
resetProgress: resetHeld / RESET_HOLD_SECONDS,
muted: audio.muted,
@ -1043,6 +1044,12 @@ async function boot() {
};
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) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
@ -1053,7 +1060,7 @@ async function boot() {
control,
segment: currentSegment,
onTarmac,
parts: quests.parts,
funds: quests.funds,
completed: quests.completed,
failed: quests.failed,
kia,

View File

@ -42,6 +42,17 @@ export interface Handling {
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 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
* 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.
* ¤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
* 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.
*
* 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
* 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
* behind, which is a decline with no way to arrest it rather than a decision
* about how hard to push the car.
*
* 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,
* 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.
*/
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 };
let remaining = parts;
let remaining = funds / FUNDS_PER_CONDITION;
// Worst-first: a car with one ruined subsystem drives worse than one that is
// 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;
}
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
* 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.
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
* 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.
@ -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
* 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 };
let remaining = parts;
let remaining = funds;
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
const worst = [...SUBSYSTEMS]
@ -255,11 +266,11 @@ export function overhaul(c: CarCondition, parts: number): { condition: CarCondit
if (!worst) break;
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
// 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 >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_FUND);
remaining -= Math.min(needed, remaining);
}

View File

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

View File

@ -19,7 +19,7 @@ export interface Opportunity {
kind: OpportunityKind;
x: number;
z: number;
/** Parts paid for stopping. */
/** Money paid for stopping. */
reward: number;
/** Whether helping shifts the front at all. */
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.',
'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,
},
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.',
'They pass you a name and a road number, then walk off before you can ask.',
],
reward: 0.14,
reward: 14,
impact: 9,
},
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.',
'Two good parts and a lot of scrap. It will do.',
],
reward: 0.11,
reward: 11,
impact: 0,
},
};

View File

@ -208,7 +208,7 @@ describe('mission lifecycle', () => {
expect(done?.kind).toBe('completed');
expect(state.active).toBeNull();
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', () => {

View File

@ -73,7 +73,7 @@ export interface Offer {
novel: boolean;
route: Route;
intel: RouteIntel;
/** Parts awarded on hand-in. */
/** Money paid on hand-in. */
reward: number;
/**
* How far this job pushes the front line, in metres. The brief asks for
@ -97,8 +97,8 @@ export interface QuestState {
completed: number;
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */
failed: number;
/** Unspent repair parts. */
parts: number;
/** Money in hand, spent on repairs and overhauls. */
funds: number;
nextId: number;
}
@ -106,7 +106,7 @@ export const createQuests = (): QuestState => ({
active: null,
completed: 0,
failed: 0,
parts: 0,
funds: 0,
nextId: 1,
});
@ -138,11 +138,11 @@ const CARGO_FRAGILITY = 1.4e-6;
* known targets are never taken, too high.
*/
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. */
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;
}
@ -232,7 +232,7 @@ export type QuestEvent =
| { kind: 'arrived' }
| { kind: 'worked'; type: MissionType }
| { 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. */
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);
state.active = null;
state.completed++;
state.parts += paid;
return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
state.funds += paid;
return { kind: 'completed', paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
}
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;

View File

@ -29,7 +29,7 @@ function played(): Snapshot {
applyMissionImpact(front, 18);
quests.completed = 3;
quests.failed = 1;
quests.parts = 0.42;
quests.funds = 42;
quests.nextId = 7;
return {
@ -78,7 +78,7 @@ describe('save round trip', () => {
expect(restored.heat.level).toEqual(source.heat.level);
expect(restored.quests.completed).toBe(3);
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
// ratchet the KIA rule exists to drive resets every time you close it.
expect(restored.kia).toBe(2);

View File

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

View File

@ -3,6 +3,7 @@ import { generateWorld } from './world';
import {
applyWear,
canOverhaul,
FUNDS_PER_CONDITION,
deriveHandling,
freshCondition,
overhaul,
@ -151,30 +152,31 @@ describe('repair', () => {
const c = damaged();
expect(c.ceiling.chassis).toBeLessThan(1);
// 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).toBeCloseTo(c.ceiling.chassis, 6);
});
it('never lifts a subsystem above its ceiling', () => {
const c = damaged();
const restored = repair(c, 99).condition;
const restored = repair(c, 9999).condition;
for (const part of SUBSYSTEMS) {
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
}
});
it('hands back parts it could not use', () => {
const { unused } = repair(freshCondition(), 5);
expect(unused).toBeCloseTo(5, 6);
const { unused } = repair(freshCondition(), 500);
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 = {
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
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.engine).toBe(0.9);
});
@ -186,7 +188,7 @@ describe('repair', () => {
for (let i = 0; i < 30; i++) {
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.
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
@ -255,7 +257,7 @@ describe('overhaul', () => {
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;
c = repair(c, 9999).condition;
}
return c;
};
@ -263,13 +265,13 @@ describe('overhaul', () => {
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;
const after = overhaul(c, 300).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;
const after = overhaul(c, 300).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.
@ -281,7 +283,7 @@ describe('overhaul', () => {
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;
const after = overhaul(c, 100).condition;
expect(after.ceiling.tires).toBeGreaterThan(0.5);
expect(after.ceiling.engine).toBe(0.9);
});
@ -291,21 +293,21 @@ describe('overhaul', () => {
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 repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 50).condition;
const rebuilt = overhaul(c, 50).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);
const { condition, unused } = overhaul(battered(), 9999);
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', () => {
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
// nothing; unaffordable and it is the ratchet it was before.
const crashed = applyWear(freshCondition(), {
@ -314,15 +316,17 @@ describe('overhaul', () => {
throttle: 0,
impactForce: 2.47e6,
});
const lost = 1 - crashed.ceiling.chassis;
let spent = 0;
const MISSION_PAY = 30;
let missions = 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;
while (canOverhaul(c) && missions < 200) {
c = overhaul(c, MISSION_PAY).condition;
missions++;
}
expect(lost).toBeGreaterThan(0.15);
expect(spent / 0.3).toBeGreaterThan(4);
expect(spent / 0.3).toBeLessThan(12);
expect(1 - crashed.ceiling.chassis).toBeGreaterThan(0.15);
// Putting the whole car back to factory after one bad crash: an evening's
// 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,
impactForce: 0,
});
return { done, cargo, parts: state.parts };
return { done, cargo, paid: state.funds };
}
describe('carrying cargo home', () => {
@ -96,13 +96,13 @@ describe('carrying cargo home', () => {
const clean = runMission('retrieval');
const rough = runMission('retrieval', 4e4);
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', () => {
const rough = runMission('supply', 8e4);
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 { HeatLevel } from '../sim/heat';
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.
@ -51,7 +52,7 @@ function describe(offer: Offer): string {
* discovers it exists.
*/
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() {
const el = document.createElement('div');
@ -92,7 +93,7 @@ export function createBoard() {
${MISSION_SHAPE[offer.type].label}
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
· ${(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">${describe(offer)}</div>
</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 { money } from './currency';
import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
import type { Control } from '../sim/regions';
@ -54,7 +55,7 @@ export interface HudModel {
failed: number;
/** Cars written off underneath the player. */
kia: number;
parts: number;
funds: number;
notice: string;
/** 0..1 toward wiping the campaign, while R is held. */
resetProgress: number;
@ -141,7 +142,7 @@ export function createHud(seed: number, debug = false) {
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
`${(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}` : '') +
// 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.