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:
parent
1019c3aec3
commit
6c1132b133
40
README.md
40
README.md
@ -31,6 +31,7 @@ seeds may be numbers or words.
|
|||||||
| `R` | respawn the car — **does not** repair it |
|
| `R` | respawn the car — **does not** repair it |
|
||||||
| `1`–`3` | accept a mission, when parked at a base |
|
| `1`–`3` | accept a mission, when parked at a base |
|
||||||
| `X` | give up the mission in hand, when parked at a base |
|
| `X` | give up the mission in hand, when parked at a base |
|
||||||
|
| `C` | overhaul the car, when parked at a base |
|
||||||
| `M` | sound on/off |
|
| `M` | sound on/off |
|
||||||
| hold `R` | wipe the campaign and start over (5 seconds) |
|
| hold `R` | wipe the campaign and start over (5 seconds) |
|
||||||
|
|
||||||
@ -254,19 +255,35 @@ sim: crossing a border, a road you have made notorious, a car past saving, or
|
|||||||
the front genuinely drifting your way, which is the brief's "the enemy has other
|
the front genuinely drifting your way, which is the brief's "the enemy has other
|
||||||
priorities" said out loud without ever showing a number.
|
priorities" said out loud without ever showing a number.
|
||||||
|
|
||||||
### Rewards, and why repairs exist
|
### Rewards, repairs, and the overhaul
|
||||||
|
|
||||||
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is
|
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. The currency sign
|
||||||
|
is `¤` — Unicode's generic one, which looks like money and is nobody's actual
|
||||||
|
money, for a place whose banks are somebody else's problem. That multiplier is
|
||||||
the phase's tuning dial: if you never take the new target it is too low, and if
|
the phase's tuning dial: if you never take the new target it is too low, and if
|
||||||
you never take the known one it is too high.
|
you never take the known one it is too high.
|
||||||
|
|
||||||
Payment is in **parts**, which repair the car — the only thing in the game that
|
Payment is money, and money repairs the car. Repairs go to the worst subsystem first and stop at
|
||||||
pushes condition back up. Repairs go to the worst subsystem first and stop at
|
|
||||||
its **ceiling**, which drops permanently with every bit of damage taken
|
its **ceiling**, which drops permanently with every bit of damage taken
|
||||||
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
|
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
|
||||||
still capable of, but never to what it was. The HUD bars show this directly: `█`
|
still capable of, but never to what it was. The HUD bars show this directly: `█`
|
||||||
is current condition, `·` is what repairs could still reach, `×` is gone.
|
is current condition, `·` is what repairs could still reach, `×` is gone.
|
||||||
|
|
||||||
|
Repairs happen automatically on hand-in, and they stop at the ceiling. Pressing
|
||||||
|
`C` at a base does the other thing: an **overhaul**, which buys the *ceiling*
|
||||||
|
itself back at 0.12 a point per ¤1, worst subsystem first. It deliberately does
|
||||||
|
not touch the current level — it raises what the car could be, and ordinary
|
||||||
|
repair still has to fill it in.
|
||||||
|
|
||||||
|
The ceiling used to fall and nothing else: the only number in the game that
|
||||||
|
moved one way, with no counterplay at any price. That reads less as a design
|
||||||
|
pillar than as a punishment you cannot answer. Now decline is something you can
|
||||||
|
fight, and what keeps it meaningful is the exchange rate rather than the
|
||||||
|
impossibility — putting a car back to factory after one bad crash is about a
|
||||||
|
dozen missions. Early on there is never a surplus to spend, so the option is
|
||||||
|
invisible; it only becomes the thing to save for once the ceiling is what is
|
||||||
|
actually holding you back.
|
||||||
|
|
||||||
This is what gives the board's reward a reason to matter. Without an economy the
|
This is what gives the board's reward a reason to matter. Without an economy the
|
||||||
choice between a safe route and a risky one has no stakes; with one, the risky
|
choice between a safe route and a risky one has no stakes; with one, the risky
|
||||||
job is what keeps the car alive a while longer.
|
job is what keeps the car alive a while longer.
|
||||||
@ -278,7 +295,7 @@ Measured on the running game, a head-on into a building at 85 km/h comes back
|
|||||||
from Rapier as a single contact event of about **2.5e6 N** — twenty-five units
|
from Rapier as a single contact event of about **2.5e6 N** — twenty-five units
|
||||||
of "impact" against the old 1e5 reference. Through the old coefficients that was
|
of "impact" against the old 1e5 reference. Through the old coefficients that was
|
||||||
123% of the chassis and 37% of its ceiling, gone, in one step, in a game whose
|
123% of the chassis and 37% of its ceiling, gone, in one step, in a game whose
|
||||||
missions pay about 0.3 parts. One crash ended the campaign, and the campaign had
|
missions pay about ¤30. One crash ended the campaign, and the campaign had
|
||||||
no way to end.
|
no way to end.
|
||||||
|
|
||||||
Everything is now anchored to that measured figure, and damage is capped per
|
Everything is now anchored to that measured figure, and damage is capped per
|
||||||
@ -288,7 +305,7 @@ crash costs about 15% of the chassis and 5% of its ceiling.
|
|||||||
|
|
||||||
Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean
|
Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean
|
||||||
kilometres took them from new to ruined while a two-kilometre round trip paid
|
kilometres took them from new to ruined while a two-kilometre round trip paid
|
||||||
0.3 parts against 0.24 of condition spent. Driving perfectly barely broke even.
|
¤30 against 0.24 of condition spent. Driving perfectly barely broke even.
|
||||||
At a quarter of that, a typical mission spends about 0.06, so roughly two clean
|
At a quarter of that, a typical mission spends about 0.06, so roughly two clean
|
||||||
runs pay for one bad crash — enough slack to gamble with, not enough to ignore.
|
runs pay for one bad crash — enough slack to gamble with, not enough to ignore.
|
||||||
|
|
||||||
@ -305,7 +322,7 @@ front line goes back to the enemy, through the same path a finished mission uses
|
|||||||
so the war moves for one reason only.
|
so the war moves for one reason only.
|
||||||
|
|
||||||
The replacement is a real car — patched to its ceilings, because one that still
|
The replacement is a real car — patched to its ceilings, because one that still
|
||||||
needed the parts you do not have would just kill you again on the way out of the
|
needed money you do not have would just kill you again on the way out of the
|
||||||
yard — and every ceiling is 8% down on the one you wrote off. That is the
|
yard — and every ceiling is 8% down on the one you wrote off. That is the
|
||||||
ratchet applied at the one moment repairs cannot reach.
|
ratchet applied at the one moment repairs cannot reach.
|
||||||
|
|
||||||
@ -609,14 +626,15 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in
|
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in
|
||||||
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
|
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
|
||||||
not something tuned by feel. That tuning *is* Phase 0's verification gate.
|
not something tuned by feel. That tuning *is* Phase 0's verification gate.
|
||||||
- **Wear rates are tuned against the parts economy** rather than by feel: a
|
- **Wear rates are tuned against the economy** rather than by feel: a
|
||||||
clean mission spends about a fifth of what it pays, and a hard crash costs
|
clean mission spends about a fifth of what it pays, and a hard crash costs
|
||||||
roughly two clean missions. Those ratios are the dial, not the raw numbers.
|
roughly two clean missions. Those ratios are the dial, not the raw numbers.
|
||||||
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
||||||
rate changes.
|
rate changes.
|
||||||
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
|
- **Repairs are automatic on hand-in.** Money gets spent the moment you report
|
||||||
back. The brief wants repair to be a physical, on-foot act with scavenged
|
back; the overhaul under `C` is the only deliberate spend. The brief wants
|
||||||
parts; that is Phase 6.
|
repair to be a physical, on-foot act with scavenged parts; that is still
|
||||||
|
deferred.
|
||||||
- **The board's route preview assumes the shortest path.** If you drive a
|
- **The board's route preview assumes the shortest path.** If you drive a
|
||||||
different way, the intel you were shown described a route you did not take.
|
different way, the intel you were shown described a route you did not take.
|
||||||
`findRoute` already accepts a cost function, so a "safest route" preview is a
|
`findRoute` already accepts a cost function, so a "safest route" preview is a
|
||||||
|
|||||||
42
src/debug.ts
42
src/debug.ts
@ -16,6 +16,7 @@ import type { SceneView } from './render/scene';
|
|||||||
import type { PhysicsWorld } from './physics/physics';
|
import type { PhysicsWorld } from './physics/physics';
|
||||||
import type { LoopHandlers } from './core/loop';
|
import type { LoopHandlers } from './core/loop';
|
||||||
import { STEP } from './core/loop';
|
import { STEP } from './core/loop';
|
||||||
|
import { CURRENCY } from './ui/currency';
|
||||||
|
|
||||||
export interface CaptureOptions {
|
export interface CaptureOptions {
|
||||||
/** Output width in pixels. Height follows 16:9. */
|
/** Output width in pixels. Height follows 16:9. */
|
||||||
@ -38,6 +39,9 @@ export interface DebugApi {
|
|||||||
teleport: (x: number, z: number) => void;
|
teleport: (x: number, z: number) => void;
|
||||||
condition: () => { level: { engine: number; tires: number; chassis: number } };
|
condition: () => { level: { engine: number; tires: number; chassis: number } };
|
||||||
setCondition: (level: number) => unknown;
|
setCondition: (level: number) => unknown;
|
||||||
|
/** Reads and writes the money in hand, for trying the workshop out. */
|
||||||
|
funds: () => number;
|
||||||
|
setFunds: (amount: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,7 +51,10 @@ export interface DebugApi {
|
|||||||
* is a thing you want to do *while driving*, not while typing — so condition
|
* is a thing you want to do *while driving*, not while typing — so condition
|
||||||
* gets buttons. Only ever mounted under `?debug=1`.
|
* gets buttons. Only ever mounted under `?debug=1`.
|
||||||
*/
|
*/
|
||||||
function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: DebugApi): void {
|
function mountPanel(
|
||||||
|
debug: { nudgeCondition: (by: number) => unknown; nudgeFunds: (by: number) => number },
|
||||||
|
api: DebugApi,
|
||||||
|
): void {
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
panel.id = 'debug-panel';
|
panel.id = 'debug-panel';
|
||||||
panel.style.cssText = `
|
panel.style.cssText = `
|
||||||
@ -58,14 +65,20 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
|
|||||||
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
|
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const readout = document.createElement('span');
|
const reading = (width: number) => {
|
||||||
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;';
|
const span = document.createElement('span');
|
||||||
|
span.style.cssText = `min-width: ${width}px; color: #8fa3b5;`;
|
||||||
|
return span;
|
||||||
|
};
|
||||||
|
const carReadout = reading(74);
|
||||||
|
const fundsReadout = reading(66);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
const { engine, tires, chassis } = api.condition().level;
|
const { engine, tires, chassis } = api.condition().level;
|
||||||
readout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
|
carReadout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
|
||||||
|
fundsReadout.textContent = `${CURRENCY}${Math.round(api.funds())}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const button = (label: string, by: number) => {
|
const button = (label: string, apply: () => void) => {
|
||||||
const element = document.createElement('button');
|
const element = document.createElement('button');
|
||||||
element.textContent = label;
|
element.textContent = label;
|
||||||
element.style.cssText = `
|
element.style.cssText = `
|
||||||
@ -74,7 +87,7 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
|
|||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
`;
|
`;
|
||||||
element.addEventListener('click', () => {
|
element.addEventListener('click', () => {
|
||||||
debug.nudgeCondition(by);
|
apply();
|
||||||
refresh();
|
refresh();
|
||||||
// Keep the keyboard on the game, or space starts pressing this button.
|
// Keep the keyboard on the game, or space starts pressing this button.
|
||||||
element.blur();
|
element.blur();
|
||||||
@ -83,9 +96,14 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
|
|||||||
};
|
};
|
||||||
|
|
||||||
panel.append(document.createTextNode('debug'));
|
panel.append(document.createTextNode('debug'));
|
||||||
button('−', -0.15);
|
button('−', () => debug.nudgeCondition(-0.15));
|
||||||
button('+', +0.15);
|
button('+', () => debug.nudgeCondition(+0.15));
|
||||||
panel.append(readout);
|
panel.append(carReadout);
|
||||||
|
// Money, so the workshop and the board can be exercised without first
|
||||||
|
// driving eight missions to afford looking at them.
|
||||||
|
button('−¤', () => debug.nudgeFunds(-50));
|
||||||
|
button('+¤', () => debug.nudgeFunds(+50));
|
||||||
|
panel.append(fundsReadout);
|
||||||
document.body.append(panel);
|
document.body.append(panel);
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@ -168,6 +186,12 @@ export function exposeDebug(api: DebugApi): void {
|
|||||||
const now = api.condition().level;
|
const now = api.condition().level;
|
||||||
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
|
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
|
||||||
},
|
},
|
||||||
|
/** Money in hand. */
|
||||||
|
funds: api.funds,
|
||||||
|
setFunds: api.setFunds,
|
||||||
|
nudgeFunds(by: number) {
|
||||||
|
return api.setFunds(api.funds() + by);
|
||||||
|
},
|
||||||
info: api.info,
|
info: api.info,
|
||||||
state: api.state,
|
state: api.state,
|
||||||
three: THREE,
|
three: THREE,
|
||||||
|
|||||||
35
src/main.ts
35
src/main.ts
@ -61,6 +61,7 @@ import { createHud } from './ui/hud';
|
|||||||
import { createBoard } from './ui/board';
|
import { createBoard } from './ui/board';
|
||||||
import { createAudio } from './audio/audio';
|
import { createAudio } from './audio/audio';
|
||||||
import { CAR, WHEELS } from './carSpec';
|
import { CAR, WHEELS } from './carSpec';
|
||||||
|
import { CURRENCY } from './ui/currency';
|
||||||
|
|
||||||
/** What the wheel reads while nobody is holding it. */
|
/** What the wheel reads while nobody is holding it. */
|
||||||
const DEAD_HANDS = {
|
const DEAD_HANDS = {
|
||||||
@ -557,14 +558,14 @@ async function boot() {
|
|||||||
if (base && stopped && cmd.overhaul) {
|
if (base && stopped && cmd.overhaul) {
|
||||||
if (!canOverhaul(condition)) {
|
if (!canOverhaul(condition)) {
|
||||||
say('Nothing to rebuild. She is as good as she ever was.', 4);
|
say('Nothing to rebuild. She is as good as she ever was.', 4);
|
||||||
} else if (quests.parts < 0.05) {
|
} else if (quests.funds < 1) {
|
||||||
say('No parts to work with. Bring something back first.', 4);
|
say(`Nothing to pay with. Bring something back first.`, 4);
|
||||||
} else {
|
} else {
|
||||||
const before = condition.ceiling;
|
const before = condition.ceiling;
|
||||||
const done = overhaul(condition, quests.parts);
|
const done = overhaul(condition, quests.funds);
|
||||||
const spent = quests.parts - done.unused;
|
const spent = quests.funds - done.unused;
|
||||||
condition = done.condition;
|
condition = done.condition;
|
||||||
quests.parts = done.unused;
|
quests.funds = done.unused;
|
||||||
const gained =
|
const gained =
|
||||||
condition.ceiling.engine +
|
condition.ceiling.engine +
|
||||||
condition.ceiling.tires +
|
condition.ceiling.tires +
|
||||||
@ -577,7 +578,7 @@ async function boot() {
|
|||||||
say('Nothing worth stripping her down for. She is near enough new.', 4);
|
say('Nothing worth stripping her down for. She is near enough new.', 4);
|
||||||
} else {
|
} else {
|
||||||
say(
|
say(
|
||||||
`Stripped and rebuilt. ${spent.toFixed(2)} parts, and she will take ` +
|
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
|
||||||
`${(gained * 100).toFixed(0)}% more than she would have.`,
|
`${(gained * 100).toFixed(0)}% more than she would have.`,
|
||||||
7,
|
7,
|
||||||
);
|
);
|
||||||
@ -690,13 +691,13 @@ async function boot() {
|
|||||||
// The line moves because of what the player did. They are not told by
|
// The line moves because of what the player did. They are not told by
|
||||||
// how much — they find out by going back and looking.
|
// how much — they find out by going back and looking.
|
||||||
applyMissionImpact(front, event.impact);
|
applyMissionImpact(front, event.impact);
|
||||||
const outcome = repair(condition, quests.parts);
|
const outcome = repair(condition, quests.funds);
|
||||||
const spent = quests.parts - outcome.unused;
|
const spent = quests.funds - outcome.unused;
|
||||||
condition = outcome.condition;
|
condition = outcome.condition;
|
||||||
quests.parts = outcome.unused;
|
quests.funds = outcome.unused;
|
||||||
say(
|
say(
|
||||||
spent > 0.001
|
spent > 0.5
|
||||||
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
|
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
|
||||||
: 'Handed in. Nothing left worth repairing.',
|
: 'Handed in. Nothing left worth repairing.',
|
||||||
);
|
);
|
||||||
// Checkpoint: a finished run is the most expensive thing to lose.
|
// Checkpoint: a finished run is the most expensive thing to lose.
|
||||||
@ -723,7 +724,7 @@ async function boot() {
|
|||||||
);
|
);
|
||||||
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
|
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
|
||||||
else if (found?.kind === 'helped') {
|
else if (found?.kind === 'helped') {
|
||||||
quests.parts += found.opportunity.reward;
|
quests.funds += found.opportunity.reward;
|
||||||
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
|
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
|
||||||
say(found.opportunity.done, 7);
|
say(found.opportunity.done, 7);
|
||||||
persistence.checkpoint(elapsed, snapshot());
|
persistence.checkpoint(elapsed, snapshot());
|
||||||
@ -1007,7 +1008,7 @@ async function boot() {
|
|||||||
completed: quests.completed,
|
completed: quests.completed,
|
||||||
failed: quests.failed,
|
failed: quests.failed,
|
||||||
kia,
|
kia,
|
||||||
parts: quests.parts,
|
funds: quests.funds,
|
||||||
notice: elapsed < noticeUntil ? notice : '',
|
notice: elapsed < noticeUntil ? notice : '',
|
||||||
resetProgress: resetHeld / RESET_HOLD_SECONDS,
|
resetProgress: resetHeld / RESET_HOLD_SECONDS,
|
||||||
muted: audio.muted,
|
muted: audio.muted,
|
||||||
@ -1043,6 +1044,12 @@ async function boot() {
|
|||||||
};
|
};
|
||||||
return condition;
|
return condition;
|
||||||
},
|
},
|
||||||
|
funds: () => quests.funds,
|
||||||
|
/** Set the money in hand. Nothing in the game hands out money like this. */
|
||||||
|
setFunds(amount: number) {
|
||||||
|
quests.funds = Math.max(0, amount);
|
||||||
|
return quests.funds;
|
||||||
|
},
|
||||||
teleport(x, z) {
|
teleport(x, z) {
|
||||||
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
|
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
|
||||||
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
@ -1053,7 +1060,7 @@ async function boot() {
|
|||||||
control,
|
control,
|
||||||
segment: currentSegment,
|
segment: currentSegment,
|
||||||
onTarmac,
|
onTarmac,
|
||||||
parts: quests.parts,
|
funds: quests.funds,
|
||||||
completed: quests.completed,
|
completed: quests.completed,
|
||||||
failed: quests.failed,
|
failed: quests.failed,
|
||||||
kia,
|
kia,
|
||||||
|
|||||||
@ -42,6 +42,17 @@ export interface Handling {
|
|||||||
steeringPull: number;
|
steeringPull: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money that buys one full point of a subsystem's condition.
|
||||||
|
*
|
||||||
|
* The sim thinks in condition, which runs 0..1; the player thinks in what is in
|
||||||
|
* their pocket. Fixing the exchange rate here lets every balance number stay in
|
||||||
|
* the units it is actually about, and keeps the scale of the currency a purely
|
||||||
|
* presentational decision — ¤34 for a supply run reads as a payment, where 0.34
|
||||||
|
* read as a fraction of something unnamed.
|
||||||
|
*/
|
||||||
|
export const FUNDS_PER_CONDITION = 100;
|
||||||
|
|
||||||
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
||||||
|
|
||||||
export const freshCondition = (): CarCondition => ({
|
export const freshCondition = (): CarCondition => ({
|
||||||
@ -129,7 +140,7 @@ const PERMANENT_SHARE = 0.3;
|
|||||||
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25
|
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25
|
||||||
* units of impact, which through the coefficients below wrote off the chassis —
|
* units of impact, which through the coefficients below wrote off the chassis —
|
||||||
* and 37% of its ceiling — in one hit, on a car whose whole economy pays about
|
* and 37% of its ceiling — in one hit, on a car whose whole economy pays about
|
||||||
* 0.3 parts a mission. One crash ended the campaign.
|
* ¤30 a mission. One crash ended the campaign.
|
||||||
*
|
*
|
||||||
* At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by
|
* At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by
|
||||||
* the coefficients below, and they are deliberately brutal: a proper head-on
|
* the coefficients below, and they are deliberately brutal: a proper head-on
|
||||||
@ -158,16 +169,16 @@ const MAX_DAMAGE_PER_STEP = 0.85;
|
|||||||
* Returns a new condition.
|
* Returns a new condition.
|
||||||
*
|
*
|
||||||
* Baseline wear is deliberately quicker than real life so decline is legible
|
* Baseline wear is deliberately quicker than real life so decline is legible
|
||||||
* within a session, but it has to stay inside what the parts economy can pay
|
* within a session, but it has to stay inside what the economy can pay
|
||||||
* for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean
|
* for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean
|
||||||
* kilometres — no crashes at all — took them from new to ruined, while a
|
* kilometres — no crashes at all — took them from new to ruined, while a
|
||||||
* two-kilometre round trip paid about 0.3 parts against 0.24 of condition
|
* two-kilometre round trip paid about ¤30 against 0.24 of condition
|
||||||
* spent. Driving perfectly barely broke even and one knock put you permanently
|
* spent. Driving perfectly barely broke even and one knock put you permanently
|
||||||
* behind, which is a decline with no way to arrest it rather than a decision
|
* behind, which is a decline with no way to arrest it rather than a decision
|
||||||
* about how hard to push the car.
|
* about how hard to push the car.
|
||||||
*
|
*
|
||||||
* At a quarter of that a typical mission spends about 0.06 condition against
|
* At a quarter of that a typical mission spends about 0.06 condition against
|
||||||
* ~0.3 parts earned, so roughly two clean runs pay for one bad crash. That is
|
* ~¤30 earned, so roughly two clean runs pay for one bad crash. That is
|
||||||
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
||||||
* not enough to ignore.
|
* not enough to ignore.
|
||||||
*/
|
*/
|
||||||
@ -192,12 +203,12 @@ export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spends parts on the worst subsystem first, never above its ceiling.
|
* Spends money on the worst subsystem first, never above its ceiling.
|
||||||
* Returns the new condition and whatever could not be used.
|
* Returns the new condition and whatever could not be used.
|
||||||
*/
|
*/
|
||||||
export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
|
export function repair(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
|
||||||
const level = { ...c.level };
|
const level = { ...c.level };
|
||||||
let remaining = parts;
|
let remaining = funds / FUNDS_PER_CONDITION;
|
||||||
|
|
||||||
// Worst-first: a car with one ruined subsystem drives worse than one that is
|
// Worst-first: a car with one ruined subsystem drives worse than one that is
|
||||||
// evenly tired, so that is where a scarce part belongs.
|
// evenly tired, so that is where a scarce part belongs.
|
||||||
@ -213,21 +224,21 @@ export function repair(c: CarCondition, parts: number): { condition: CarConditio
|
|||||||
remaining -= spend;
|
remaining -= spend;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { condition: { level, ceiling: c.ceiling }, unused: remaining };
|
return { condition: { level, ceiling: c.ceiling }, unused: remaining * FUNDS_PER_CONDITION };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ceiling bought back per part spent on an overhaul.
|
* Ceiling bought back per unit of currency spent on an overhaul.
|
||||||
*
|
*
|
||||||
* Deliberately a bad rate next to an ordinary repair, which buys a full point
|
* Deliberately a bad rate next to an ordinary repair, which buys a full point
|
||||||
* of condition per part. Rebuilding what a subsystem is *capable* of is not
|
* of condition per part. Rebuilding what a subsystem is *capable* of is not
|
||||||
* patching it up; it is the expensive thing you do when patching has stopped
|
* patching it up; it is the expensive thing you do when patching has stopped
|
||||||
* helping.
|
* helping.
|
||||||
*
|
*
|
||||||
* At 0.12 a crash's permanent cost (~0.24 of the chassis ceiling) takes two
|
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
|
||||||
* parts to undo, or about seven missions.
|
* undo, or seven missions' pay.
|
||||||
*/
|
*/
|
||||||
const CEILING_PER_PART = 0.12;
|
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spends parts on raising the ceiling itself, worst subsystem first.
|
* Spends parts on raising the ceiling itself, worst subsystem first.
|
||||||
@ -244,9 +255,9 @@ const CEILING_PER_PART = 0.12;
|
|||||||
* and there is never a surplus; it is only once the ceiling is what is actually
|
* and there is never a surplus; it is only once the ceiling is what is actually
|
||||||
* holding you back that parts start piling up with nowhere else to go.
|
* holding you back that parts start piling up with nowhere else to go.
|
||||||
*/
|
*/
|
||||||
export function overhaul(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
|
export function overhaul(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
|
||||||
const ceiling = { ...c.ceiling };
|
const ceiling = { ...c.ceiling };
|
||||||
let remaining = parts;
|
let remaining = funds;
|
||||||
|
|
||||||
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
|
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
|
||||||
const worst = [...SUBSYSTEMS]
|
const worst = [...SUBSYSTEMS]
|
||||||
@ -255,11 +266,11 @@ export function overhaul(c: CarCondition, parts: number): { condition: CarCondit
|
|||||||
if (!worst) break;
|
if (!worst) break;
|
||||||
|
|
||||||
const room = 1 - ceiling[worst];
|
const room = 1 - ceiling[worst];
|
||||||
const needed = room / CEILING_PER_PART;
|
const needed = room / CEILING_PER_FUND;
|
||||||
// Snap when the spend covers the whole gap, rather than landing a
|
// Snap when the spend covers the whole gap, rather than landing a
|
||||||
// rounding error short of factory and reporting the car as still rebuildable.
|
// rounding error short of factory and reporting the car as still rebuildable.
|
||||||
ceiling[worst] =
|
ceiling[worst] =
|
||||||
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_PART);
|
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_FUND);
|
||||||
remaining -= Math.min(needed, remaining);
|
remaining -= Math.min(needed, remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@ describe('what a death costs', () => {
|
|||||||
expect(quests.active).toBeNull();
|
expect(quests.active).toBeNull();
|
||||||
expect(quests.completed).toBe(0);
|
expect(quests.completed).toBe(0);
|
||||||
expect(quests.failed).toBe(1);
|
expect(quests.failed).toBe(1);
|
||||||
expect(quests.parts).toBe(0);
|
expect(quests.funds).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a setback, not a campaign reversal', () => {
|
it('is a setback, not a campaign reversal', () => {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export interface Opportunity {
|
|||||||
kind: OpportunityKind;
|
kind: OpportunityKind;
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
/** Parts paid for stopping. */
|
/** Money paid for stopping. */
|
||||||
reward: number;
|
reward: number;
|
||||||
/** Whether helping shifts the front at all. */
|
/** Whether helping shifts the front at all. */
|
||||||
impact: number;
|
impact: number;
|
||||||
@ -59,7 +59,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
|||||||
'They can walk from here. They did not ask your name, which was kind of them.',
|
'They can walk from here. They did not ask your name, which was kind of them.',
|
||||||
'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.',
|
'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.',
|
||||||
],
|
],
|
||||||
reward: 0.06,
|
reward: 6,
|
||||||
impact: 3,
|
impact: 3,
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
@ -71,7 +71,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
|||||||
'A package changes hands. Neither of you says anything worth repeating.',
|
'A package changes hands. Neither of you says anything worth repeating.',
|
||||||
'They pass you a name and a road number, then walk off before you can ask.',
|
'They pass you a name and a road number, then walk off before you can ask.',
|
||||||
],
|
],
|
||||||
reward: 0.14,
|
reward: 14,
|
||||||
impact: 9,
|
impact: 9,
|
||||||
},
|
},
|
||||||
wreck: {
|
wreck: {
|
||||||
@ -83,7 +83,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
|||||||
'You strip what will fit in the boot and leave the rest to rust.',
|
'You strip what will fit in the boot and leave the rest to rust.',
|
||||||
'Two good parts and a lot of scrap. It will do.',
|
'Two good parts and a lot of scrap. It will do.',
|
||||||
],
|
],
|
||||||
reward: 0.11,
|
reward: 11,
|
||||||
impact: 0,
|
impact: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -208,7 +208,7 @@ describe('mission lifecycle', () => {
|
|||||||
expect(done?.kind).toBe('completed');
|
expect(done?.kind).toBe('completed');
|
||||||
expect(state.active).toBeNull();
|
expect(state.active).toBeNull();
|
||||||
expect(state.completed).toBe(1);
|
expect(state.completed).toBe(1);
|
||||||
expect(state.parts).toBeGreaterThan(0);
|
expect(state.funds).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {
|
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export interface Offer {
|
|||||||
novel: boolean;
|
novel: boolean;
|
||||||
route: Route;
|
route: Route;
|
||||||
intel: RouteIntel;
|
intel: RouteIntel;
|
||||||
/** Parts awarded on hand-in. */
|
/** Money paid on hand-in. */
|
||||||
reward: number;
|
reward: number;
|
||||||
/**
|
/**
|
||||||
* How far this job pushes the front line, in metres. The brief asks for
|
* How far this job pushes the front line, in metres. The brief asks for
|
||||||
@ -97,8 +97,8 @@ export interface QuestState {
|
|||||||
completed: number;
|
completed: number;
|
||||||
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */
|
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */
|
||||||
failed: number;
|
failed: number;
|
||||||
/** Unspent repair parts. */
|
/** Money in hand, spent on repairs and overhauls. */
|
||||||
parts: number;
|
funds: number;
|
||||||
nextId: number;
|
nextId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ export const createQuests = (): QuestState => ({
|
|||||||
active: null,
|
active: null,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
parts: 0,
|
funds: 0,
|
||||||
nextId: 1,
|
nextId: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -138,11 +138,11 @@ const CARGO_FRAGILITY = 1.4e-6;
|
|||||||
* known targets are never taken, too high.
|
* known targets are never taken, too high.
|
||||||
*/
|
*/
|
||||||
const NOVELTY_BONUS = 1.8;
|
const NOVELTY_BONUS = 1.8;
|
||||||
const PARTS_PER_KM = 0.1;
|
const PAY_PER_KM = 10;
|
||||||
|
|
||||||
/** Exported so the novelty premium can be tested on like for like. */
|
/** Exported so the novelty premium can be tested on like for like. */
|
||||||
export function rewardFor(type: MissionType, route: Route, novel: boolean): number {
|
export function rewardFor(type: MissionType, route: Route, novel: boolean): number {
|
||||||
const base = 0.08 + (route.length / 1000) * PARTS_PER_KM;
|
const base = 8 + (route.length / 1000) * PAY_PER_KM;
|
||||||
return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward;
|
return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +232,7 @@ export type QuestEvent =
|
|||||||
| { kind: 'arrived' }
|
| { kind: 'arrived' }
|
||||||
| { kind: 'worked'; type: MissionType }
|
| { kind: 'worked'; type: MissionType }
|
||||||
| { kind: 'cargoDamaged'; cargo: number }
|
| { kind: 'cargoDamaged'; cargo: number }
|
||||||
| { kind: 'completed'; parts: number; impact: number; cargo: number };
|
| { kind: 'completed'; paid: number; impact: number; cargo: number };
|
||||||
|
|
||||||
/** Advances the active mission. Returns whatever just happened, if anything. */
|
/** Advances the active mission. Returns whatever just happened, if anything. */
|
||||||
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
||||||
@ -281,8 +281,8 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null
|
|||||||
const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1);
|
const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1);
|
||||||
state.active = null;
|
state.active = null;
|
||||||
state.completed++;
|
state.completed++;
|
||||||
state.parts += paid;
|
state.funds += paid;
|
||||||
return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
|
return { kind: 'completed', paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
|
||||||
}
|
}
|
||||||
|
|
||||||
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;
|
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;
|
||||||
|
|||||||
@ -29,7 +29,7 @@ function played(): Snapshot {
|
|||||||
applyMissionImpact(front, 18);
|
applyMissionImpact(front, 18);
|
||||||
quests.completed = 3;
|
quests.completed = 3;
|
||||||
quests.failed = 1;
|
quests.failed = 1;
|
||||||
quests.parts = 0.42;
|
quests.funds = 42;
|
||||||
quests.nextId = 7;
|
quests.nextId = 7;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -78,7 +78,7 @@ describe('save round trip', () => {
|
|||||||
expect(restored.heat.level).toEqual(source.heat.level);
|
expect(restored.heat.level).toEqual(source.heat.level);
|
||||||
expect(restored.quests.completed).toBe(3);
|
expect(restored.quests.completed).toBe(3);
|
||||||
expect(restored.quests.failed).toBe(1);
|
expect(restored.quests.failed).toBe(1);
|
||||||
expect(restored.quests.parts).toBeCloseTo(0.42, 9);
|
expect(restored.quests.funds).toBeCloseTo(42, 9);
|
||||||
// A campaign's history of written-off cars has to outlive the tab, or the
|
// A campaign's history of written-off cars has to outlive the tab, or the
|
||||||
// ratchet the KIA rule exists to drive resets every time you close it.
|
// ratchet the KIA rule exists to drive resets every time you close it.
|
||||||
expect(restored.kia).toBe(2);
|
expect(restored.kia).toBe(2);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import type { Intel } from './intel';
|
|||||||
import type { Boundaries, Front } from './regions';
|
import type { Boundaries, Front } from './regions';
|
||||||
import type { ActiveQuest, QuestState } from './quests';
|
import type { ActiveQuest, QuestState } from './quests';
|
||||||
|
|
||||||
export const SAVE_VERSION = 3;
|
export const SAVE_VERSION = 4;
|
||||||
|
|
||||||
export interface CarSnapshot {
|
export interface CarSnapshot {
|
||||||
position: [number, number, number];
|
position: [number, number, number];
|
||||||
@ -47,7 +47,7 @@ export interface SaveData {
|
|||||||
active: ActiveQuest | null;
|
active: ActiveQuest | null;
|
||||||
completed: number;
|
completed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
parts: number;
|
funds: number;
|
||||||
nextId: number;
|
nextId: number;
|
||||||
};
|
};
|
||||||
/** Times the car has been written off underneath the player. */
|
/** Times the car has been written off underneath the player. */
|
||||||
@ -89,7 +89,7 @@ export function serialise(s: Snapshot): SaveData {
|
|||||||
active: s.quests.active,
|
active: s.quests.active,
|
||||||
completed: s.quests.completed,
|
completed: s.quests.completed,
|
||||||
failed: s.quests.failed,
|
failed: s.quests.failed,
|
||||||
parts: s.quests.parts,
|
funds: s.quests.funds,
|
||||||
nextId: s.quests.nextId,
|
nextId: s.quests.nextId,
|
||||||
},
|
},
|
||||||
kia: s.kia,
|
kia: s.kia,
|
||||||
@ -146,7 +146,7 @@ export function apply(data: SaveData, into: Snapshot): void {
|
|||||||
into.quests.active = data.quests.active;
|
into.quests.active = data.quests.active;
|
||||||
into.quests.completed = data.quests.completed;
|
into.quests.completed = data.quests.completed;
|
||||||
into.quests.failed = data.quests.failed;
|
into.quests.failed = data.quests.failed;
|
||||||
into.quests.parts = data.quests.parts;
|
into.quests.funds = data.quests.funds;
|
||||||
into.quests.nextId = data.quests.nextId;
|
into.quests.nextId = data.quests.nextId;
|
||||||
into.kia = data.kia;
|
into.kia = data.kia;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { generateWorld } from './world';
|
|||||||
import {
|
import {
|
||||||
applyWear,
|
applyWear,
|
||||||
canOverhaul,
|
canOverhaul,
|
||||||
|
FUNDS_PER_CONDITION,
|
||||||
deriveHandling,
|
deriveHandling,
|
||||||
freshCondition,
|
freshCondition,
|
||||||
overhaul,
|
overhaul,
|
||||||
@ -151,30 +152,31 @@ describe('repair', () => {
|
|||||||
const c = damaged();
|
const c = damaged();
|
||||||
expect(c.ceiling.chassis).toBeLessThan(1);
|
expect(c.ceiling.chassis).toBeLessThan(1);
|
||||||
// No amount of parts can undo it.
|
// No amount of parts can undo it.
|
||||||
const restored = repair(c, 99).condition;
|
const restored = repair(c, 9999).condition;
|
||||||
expect(restored.level.chassis).toBeLessThan(1);
|
expect(restored.level.chassis).toBeLessThan(1);
|
||||||
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never lifts a subsystem above its ceiling', () => {
|
it('never lifts a subsystem above its ceiling', () => {
|
||||||
const c = damaged();
|
const c = damaged();
|
||||||
const restored = repair(c, 99).condition;
|
const restored = repair(c, 9999).condition;
|
||||||
for (const part of SUBSYSTEMS) {
|
for (const part of SUBSYSTEMS) {
|
||||||
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
|
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hands back parts it could not use', () => {
|
it('hands back parts it could not use', () => {
|
||||||
const { unused } = repair(freshCondition(), 5);
|
const { unused } = repair(freshCondition(), 500);
|
||||||
expect(unused).toBeCloseTo(5, 6);
|
expect(unused).toBeCloseTo(500, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('spends a scarce part on the worst subsystem', () => {
|
it('spends scarce money on the worst subsystem', () => {
|
||||||
const c: CarCondition = {
|
const c: CarCondition = {
|
||||||
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
|
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
|
||||||
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
};
|
};
|
||||||
const restored = repair(c, 0.1).condition;
|
// A tenth of a point of condition, in what the player actually carries.
|
||||||
|
const restored = repair(c, 0.1 * FUNDS_PER_CONDITION).condition;
|
||||||
expect(restored.level.tires).toBeCloseTo(0.3, 6);
|
expect(restored.level.tires).toBeCloseTo(0.3, 6);
|
||||||
expect(restored.level.engine).toBe(0.9);
|
expect(restored.level.engine).toBe(0.9);
|
||||||
});
|
});
|
||||||
@ -186,7 +188,7 @@ describe('repair', () => {
|
|||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
|
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
|
||||||
}
|
}
|
||||||
c = repair(c, 99).condition;
|
c = repair(c, 9999).condition;
|
||||||
// Fully repaired every cycle, and still worse off every cycle.
|
// Fully repaired every cycle, and still worse off every cycle.
|
||||||
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||||
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
|
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
|
||||||
@ -255,7 +257,7 @@ describe('overhaul', () => {
|
|||||||
let c = freshCondition();
|
let c = freshCondition();
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
|
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
|
||||||
c = repair(c, 99).condition;
|
c = repair(c, 9999).condition;
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
@ -263,13 +265,13 @@ describe('overhaul', () => {
|
|||||||
it('is the one thing that can push a ceiling back up', () => {
|
it('is the one thing that can push a ceiling back up', () => {
|
||||||
const c = battered();
|
const c = battered();
|
||||||
expect(c.ceiling.chassis).toBeLessThan(0.9);
|
expect(c.ceiling.chassis).toBeLessThan(0.9);
|
||||||
const after = overhaul(c, 3).condition;
|
const after = overhaul(c, 300).condition;
|
||||||
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
|
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the level alone — it buys capability, not condition', () => {
|
it('leaves the level alone — it buys capability, not condition', () => {
|
||||||
const c = battered();
|
const c = battered();
|
||||||
const after = overhaul(c, 3).condition;
|
const after = overhaul(c, 300).condition;
|
||||||
expect(after.level).toEqual(c.level);
|
expect(after.level).toEqual(c.level);
|
||||||
// Which is what makes it a *second* spend: the room it opens up still has
|
// Which is what makes it a *second* spend: the room it opens up still has
|
||||||
// to be filled by ordinary repair.
|
// to be filled by ordinary repair.
|
||||||
@ -281,7 +283,7 @@ describe('overhaul', () => {
|
|||||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||||
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
|
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
|
||||||
};
|
};
|
||||||
const after = overhaul(c, 1).condition;
|
const after = overhaul(c, 100).condition;
|
||||||
expect(after.ceiling.tires).toBeGreaterThan(0.5);
|
expect(after.ceiling.tires).toBeGreaterThan(0.5);
|
||||||
expect(after.ceiling.engine).toBe(0.9);
|
expect(after.ceiling.engine).toBe(0.9);
|
||||||
});
|
});
|
||||||
@ -291,21 +293,21 @@ describe('overhaul', () => {
|
|||||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||||
ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||||
};
|
};
|
||||||
const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 1).condition;
|
const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 50).condition;
|
||||||
const rebuilt = overhaul(c, 1).condition;
|
const rebuilt = overhaul(c, 50).condition;
|
||||||
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5;
|
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5;
|
||||||
const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
|
const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
|
||||||
expect(rebuiltGain).toBeLessThan(repairedGain);
|
expect(rebuiltGain).toBeLessThan(repairedGain);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never goes past factory, and hands back what it could not use', () => {
|
it('never goes past factory, and hands back what it could not use', () => {
|
||||||
const { condition, unused } = overhaul(battered(), 99);
|
const { condition, unused } = overhaul(battered(), 9999);
|
||||||
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
|
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
|
||||||
expect(unused).toBeGreaterThan(0);
|
expect(unused).toBeGreaterThan(0);
|
||||||
expect(canOverhaul(condition)).toBe(false);
|
expect(canOverhaul(condition)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('costs about seven missions to undo one crash', () => {
|
it('costs a run of missions to undo one crash, not one and not fifty', () => {
|
||||||
// The exchange rate is the whole design. Free and the ceiling means
|
// The exchange rate is the whole design. Free and the ceiling means
|
||||||
// nothing; unaffordable and it is the ratchet it was before.
|
// nothing; unaffordable and it is the ratchet it was before.
|
||||||
const crashed = applyWear(freshCondition(), {
|
const crashed = applyWear(freshCondition(), {
|
||||||
@ -314,15 +316,17 @@ describe('overhaul', () => {
|
|||||||
throttle: 0,
|
throttle: 0,
|
||||||
impactForce: 2.47e6,
|
impactForce: 2.47e6,
|
||||||
});
|
});
|
||||||
const lost = 1 - crashed.ceiling.chassis;
|
const MISSION_PAY = 30;
|
||||||
let spent = 0;
|
let missions = 0;
|
||||||
let c = crashed;
|
let c = crashed;
|
||||||
while (c.ceiling.chassis < 1 - 1e-9 && spent < 50) {
|
while (canOverhaul(c) && missions < 200) {
|
||||||
c = overhaul(c, 0.3).condition; // one mission's pay
|
c = overhaul(c, MISSION_PAY).condition;
|
||||||
spent += 0.3;
|
missions++;
|
||||||
}
|
}
|
||||||
expect(lost).toBeGreaterThan(0.15);
|
expect(1 - crashed.ceiling.chassis).toBeGreaterThan(0.15);
|
||||||
expect(spent / 0.3).toBeGreaterThan(4);
|
// Putting the whole car back to factory after one bad crash: an evening's
|
||||||
expect(spent / 0.3).toBeLessThan(12);
|
// work, not a weekend's and not a single job.
|
||||||
|
expect(missions).toBeGreaterThan(6);
|
||||||
|
expect(missions).toBeLessThan(20);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -82,7 +82,7 @@ function runMission(type: MissionType, impactPerStep = 0) {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
impactForce: 0,
|
impactForce: 0,
|
||||||
});
|
});
|
||||||
return { done, cargo, parts: state.parts };
|
return { done, cargo, paid: state.funds };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('carrying cargo home', () => {
|
describe('carrying cargo home', () => {
|
||||||
@ -96,13 +96,13 @@ describe('carrying cargo home', () => {
|
|||||||
const clean = runMission('retrieval');
|
const clean = runMission('retrieval');
|
||||||
const rough = runMission('retrieval', 4e4);
|
const rough = runMission('retrieval', 4e4);
|
||||||
expect(rough.cargo).toBeLessThan(clean.cargo);
|
expect(rough.cargo).toBeLessThan(clean.cargo);
|
||||||
expect(rough.parts).toBeLessThan(clean.parts);
|
expect(rough.paid).toBeLessThan(clean.paid);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves other mission types alone, however hard you crash', () => {
|
it('leaves other mission types alone, however hard you crash', () => {
|
||||||
const rough = runMission('supply', 8e4);
|
const rough = runMission('supply', 8e4);
|
||||||
expect(rough.cargo).toBe(1);
|
expect(rough.cargo).toBe(1);
|
||||||
expect(rough.parts).toBeCloseTo(runMission('supply').parts, 9);
|
expect(rough.paid).toBeCloseTo(runMission('supply').paid, 9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Base } from '../sim/bases';
|
import type { Base } from '../sim/bases';
|
||||||
import type { HeatLevel } from '../sim/heat';
|
import type { HeatLevel } from '../sim/heat';
|
||||||
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
|
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
|
||||||
|
import { money } from './currency';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What each job actually asks of you, in the words the people asking would use.
|
* What each job actually asks of you, in the words the people asking would use.
|
||||||
@ -51,7 +52,7 @@ function describe(offer: Offer): string {
|
|||||||
* discovers it exists.
|
* discovers it exists.
|
||||||
*/
|
*/
|
||||||
const OVERHAUL_FOOTER =
|
const OVERHAUL_FOOTER =
|
||||||
' · <span class="key">[C]</span> overhaul — spend parts on what she is capable of';
|
' · <span class="key">[C]</span> overhaul — spend on what she is capable of';
|
||||||
|
|
||||||
export function createBoard() {
|
export function createBoard() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@ -92,7 +93,7 @@ export function createBoard() {
|
|||||||
${MISSION_SHAPE[offer.type].label} —
|
${MISSION_SHAPE[offer.type].label} —
|
||||||
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
|
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
|
||||||
· ${(offer.route.length / 1000).toFixed(1)} km
|
· ${(offer.route.length / 1000).toFixed(1)} km
|
||||||
· ${offer.reward.toFixed(2)} parts
|
· ${money(offer.reward)}
|
||||||
<div class="meta">${BRIEFING[offer.type]}</div>
|
<div class="meta">${BRIEFING[offer.type]}</div>
|
||||||
<div class="meta">${describe(offer)}</div>
|
<div class="meta">${describe(offer)}</div>
|
||||||
</li>`,
|
</li>`,
|
||||||
|
|||||||
12
src/ui/currency.ts
Normal file
12
src/ui/currency.ts
Normal 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)}`;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
|
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
|
||||||
|
import { money } from './currency';
|
||||||
import type { HeatLevel } from '../sim/heat';
|
import type { HeatLevel } from '../sim/heat';
|
||||||
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
|
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
|
||||||
import type { Control } from '../sim/regions';
|
import type { Control } from '../sim/regions';
|
||||||
@ -54,7 +55,7 @@ export interface HudModel {
|
|||||||
failed: number;
|
failed: number;
|
||||||
/** Cars written off underneath the player. */
|
/** Cars written off underneath the player. */
|
||||||
kia: number;
|
kia: number;
|
||||||
parts: number;
|
funds: number;
|
||||||
notice: string;
|
notice: string;
|
||||||
/** 0..1 toward wiping the campaign, while R is held. */
|
/** 0..1 toward wiping the campaign, while R is held. */
|
||||||
resetProgress: number;
|
resetProgress: number;
|
||||||
@ -141,7 +142,7 @@ export function createHud(seed: number, debug = false) {
|
|||||||
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
|
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
|
||||||
`${(condition.level[part] * 100).toFixed(0)}%`,
|
`${(condition.level[part] * 100).toFixed(0)}%`,
|
||||||
),
|
),
|
||||||
`parts ${model.parts.toFixed(2)} runs ${model.completed}` +
|
`funds ${money(model.funds).padEnd(8)} runs ${model.completed}` +
|
||||||
(model.failed > 0 ? ` lost ${model.failed}` : '') +
|
(model.failed > 0 ? ` lost ${model.failed}` : '') +
|
||||||
// Cars are counted, not lives. There is no stock of them to run out
|
// Cars are counted, not lives. There is no stock of them to run out
|
||||||
// of; the number is there so a long campaign reads as a history.
|
// of; the number is there so a long campaign reads as a history.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user