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
co-authored by Claude Opus 5
parent 1019c3aec3
commit 6c1132b133
15 changed files with 179 additions and 101 deletions
+33 -9
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,
+21 -14
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,
+27 -16
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);
}
+1 -1
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', () => {
+4 -4
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,
},
};
+1 -1
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', () => {
+9 -9
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;
+2 -2
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);
+4 -4
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;
}
+27 -23
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);
});
});
+3 -3
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);
});
});
+3 -2
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
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)}`;
+3 -2
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.