From 6c1132b1335d32aea85efe3d9273eb4fde57866d Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 08:06:57 +0200 Subject: [PATCH] Parts become money, and the debug panel can hand you some MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- README.md | 40 +++++++++++++++++++++++--------- src/debug.ts | 42 +++++++++++++++++++++++++-------- src/main.ts | 35 +++++++++++++++++----------- src/sim/car.ts | 43 +++++++++++++++++++++------------- src/sim/kia.test.ts | 2 +- src/sim/opportunities.ts | 8 +++---- src/sim/quests.test.ts | 2 +- src/sim/quests.ts | 18 +++++++-------- src/sim/save.test.ts | 4 ++-- src/sim/save.ts | 8 +++---- src/sim/sim.test.ts | 50 ++++++++++++++++++++++------------------ src/sim/texture.test.ts | 6 ++--- src/ui/board.ts | 5 ++-- src/ui/currency.ts | 12 ++++++++++ src/ui/hud.ts | 5 ++-- 15 files changed, 179 insertions(+), 101 deletions(-) create mode 100644 src/ui/currency.ts diff --git a/README.md b/README.md index 34fd57b..58d0ee9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/debug.ts b/src/debug.ts index d6760ce..081faef 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -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, diff --git a/src/main.ts b/src/main.ts index 45e7e6a..2b7dc20 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, diff --git a/src/sim/car.ts b/src/sim/car.ts index faee690..8f3de17 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -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); } diff --git a/src/sim/kia.test.ts b/src/sim/kia.test.ts index 79e90e4..820f344 100644 --- a/src/sim/kia.test.ts +++ b/src/sim/kia.test.ts @@ -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', () => { diff --git a/src/sim/opportunities.ts b/src/sim/opportunities.ts index 49c6819..4e1b6ef 100644 --- a/src/sim/opportunities.ts +++ b/src/sim/opportunities.ts @@ -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 { 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', () => { diff --git a/src/sim/quests.ts b/src/sim/quests.ts index df6b317..1d4addc 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -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; diff --git a/src/sim/save.test.ts b/src/sim/save.test.ts index 53c6993..840cdc3 100644 --- a/src/sim/save.test.ts +++ b/src/sim/save.test.ts @@ -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); diff --git a/src/sim/save.ts b/src/sim/save.ts index 0b17f44..d87cde7 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -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; } diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index 105e01f..e6dd26c 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -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); }); }); diff --git a/src/sim/texture.test.ts b/src/sim/texture.test.ts index 5639e87..6d0e41f 100644 --- a/src/sim/texture.test.ts +++ b/src/sim/texture.test.ts @@ -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); }); }); diff --git a/src/ui/board.ts b/src/ui/board.ts index ebd8a13..64907f6 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -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 = - ' · [C] overhaul — spend parts on what she is capable of'; + ' · [C] 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 ? 'new target' : 'known target'} · ${(offer.route.length / 1000).toFixed(1)} km - · ${offer.reward.toFixed(2)} parts + · ${money(offer.reward)}
${BRIEFING[offer.type]}
${describe(offer)}
`, diff --git a/src/ui/currency.ts b/src/ui/currency.ts new file mode 100644 index 0000000..b663684 --- /dev/null +++ b/src/ui/currency.ts @@ -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)}`; diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 83a9c17..86311dd 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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.