Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper 77eaa3ac98 Step 18.1: sim: fuel-to-payout balance check
CI / check (push) Successful in 6m17s Details
For every purchasable ship and route it can fly, the leg's fuel at the
origin's price against the median payout of fitting contracts; the
balance script prints the table (decision 0.5.110).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:22:26 -04:00
fireshaper e5eebdd8aa Record CR-018: fuel tuning (Phase 18)
Received as CR-017 for Phase 17; renumbered. Decisions 0.5.110-0.5.113.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:17:48 -04:00
7 changed files with 285 additions and 1 deletions

View File

@ -1582,3 +1582,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 17.7
- **CR-001's own text is amended with notes, not rewritten**, as with earlier CRs: its 0.5.22 is marked as plan 0.5.30 and points at CR-017.
## CR-018 recorded (Fuel Tuning)
- **Numbering:** received as "CR-017 — Fuel Tuning" for "Phase 17"; stored as **CR-018**, built as **Phase 18** (its 17.n = 18.n). Its decisions CR-017.D1–D4 are **0.5.110–0.5.113**. Its references: CR-010 → CR-013 (step 10J.3 → 10M.3), CR-011 → CR-014, CR-014 → CR-015 (steps 14.n → 15.n; CR-014.D5 → 0.5.97), CR-015 → CR-016. The numbering note at the top of the CR file has the full map.
- **D2 replaces the quarter-tank deal ships** (user decision during CR-015): the CR assumes deal ships may start empty; here they started at a quarter tank. With full tanks and cheaper fuel, `config.deal.tankShare` becomes 1.
## Step 18.1
- **Fuel is the same for any load** (fuel has no mass, decision 0.5.94), so "a typical loaded leg" is just the leg's fuel at the origin's price.
- **"Contracts that fit the ship" means the hold**: mass at most the cargo capacity, any cargo class (a pilot can learn the skill), measured on boards generated with that ship as the fleet so the boards fit it as they would in play.
- **One price can't meet the 15–25% band** (user decision, 2026-10-10): shares spread over 100× because bigger ships carry richer jobs for the same fuel. The user chose: halve the base price (CR-018's starting point), report every row, and fail only on a row above 25%, with the Clipper's Mars → Earth leg (few small Mars → Earth jobs fit its 30 t hold) as the known exception.

View File

@ -0,0 +1,87 @@
# Change Request CR-018 — Fuel Tuning
> **Numbering note (added when recorded):** this request arrived titled "CR-017 — Fuel Tuning" for "Phase 17". CR-017 (tutorial XP and skill point prompt) and Phase 17 already existed, so it is stored as **CR-018** and done as **Phase 18**: every "17.n" below means **18.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-016" = Phase 13 and **CR-001 to CR-017**; "CR-014 fixed map and fuel tanks" = **CR-015** (its steps 14.n are **15.n**; its §3 and §4 are CR-015 §3 and §4; its "CR-014.D5" Auto default is plan **0.5.97 Refueling**); "CR-015" (Venus, Io, Europa and the belt) = **CR-016** (§3 is its reach additions, decision 0.5.102); "CR-010 veteran Clipper" = **CR-013** (its "10J.3" is CR-013's Step **10M.3**, written there as 10J.3); "CR-011 local-first" = **CR-014**. "Plan 12.1" is the same. Its new decisions CR-017.D1–D4 are recorded as **0.5.110 Base fuel price, 0.5.111 Deal ships full, 0.5.112 Manual by default, 0.5.113 Low-fuel warning**.
>
> **On recording:** the deal's ships do not start empty in this build; they start with a quarter tank (user decision during CR-015, `config.deal.tankShare` 0.25, DECISIONS "Deal ships come with a quarter tank"), which kept the first Market purchase in its balance window. D2 (0.5.111) replaces that decision: `tankShare` becomes 1.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-016 complete
**Do this:** as **Phase 17**
**Related docs:** `change-request-fixed-map-and-fuel-tanks.md` (CR-014), `change-request-veteran-clipper.md` (CR-010), `change-request-local-first.md` (CR-011), `DECISIONS.md`
This change request is written for the local-first engine (CR-011).
---
## 1. Why
Playtesting after CR-014: auto top-up keeps the player at 0 cr. Every landing spends all available cash on fuel, so the player can never save for ships, upgrades, or loan payments. Three changes:
1. **Lower the base fuel price**, with a balance check so fuel is a sensible share of what a job pays.
2. **The deal's ships start with full tanks.** The Courier and Clipper from Halvorsen's deal are created separately from purchased ships and may currently start empty, so the first landings buy whole tanks at Earth's price.
3. **Manual refueling is the default.** New ships, including the deal's ships, start on Manual. Auto top-up remains an option per ship.
## 2. Rules
| Rule | Detail |
|---|---|
| Base fuel price | Lowered. Start at **half** the current value, then tune until the balance check below passes. Location multipliers (CR-014/CR-015) are unchanged. |
| Fuel-to-payout check | For each ship model, on the routes it can fly, the fuel cost of a typical loaded leg (bought at the origin's price) is **15–25% of the median payout** of contracts on that route that fit the ship. Measured over generated boards. |
| Deal ships | The deal's Courier and Clipper are created with full tanks, valued at Earth's fuel price for the tank's average price. |
| Default refuel policy | **Manual** for every new ship: deal ships and purchased ships. Players can switch any ship to Auto top-up. |
| Low-fuel warning | Because ships no longer refuel on their own, a ship's Fleet card shows a **Low fuel** warning when its tank can't fly its longest "yes" leg from its current location (CR-014 §4 / CR-015 §3). The existing "Refuel here first" button in the dispatch sheet stays as the main way to refuel. |
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- **Save migration:** every existing ship's refuel policy is set to Manual (no player chose Auto on purpose; it was the default). Fuel levels and credits are unchanged.
- Update existing tests whose expectations change (default policy, fuel costs in summaries and dispatch sheets, deal state). Update `progress.md` after every step.
- Stop for the user's review at the end of Phase 17.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-017.D1–D4 here; note the mapping).
- **CR-017.D1 Base fuel price.** Lowered and tuned to the fuel-to-payout check (§2).
- **CR-017.D2 Deal ships full.** The deal's ships start with full tanks (extends CR-014's "new ships delivered full" to ships created by the deal).
- **CR-017.D3 Manual by default.** New ships default to Manual refueling (replaces CR-014 CR-014.D5's Auto default). Auto top-up still exists and behaves as before.
- **CR-017.D4 Low-fuel warning.** As in §2.
## 5. Steps — Phase 17
### Step 17.1 — Sim: fuel-to-payout balance check
- Add a balance test that, for each ship model and each route it can fly, generates boards, takes contracts that fit the ship, and compares fuel cost for the leg (at the origin's price) with the median payout.
- **Test:** The check runs and reports, per ship and route, the fuel share of payout. (It will fail until Step 17.2.)
### Step 17.2 — Content: lower the base fuel price
- Halve the base fuel price, run the check, and adjust until every ship and route lands in 15–25%. Change only the base price unless a single route can't fit; then note it in `DECISIONS.md` rather than changing location multipliers.
- **Test:** The Step 17.1 check passes for every ship model and route.
### Step 17.3 — Engine: deal ships start full; Manual by default
- The accept-deal command creates the Courier and Clipper with full tanks.
- Every newly created ship (deal or purchase) has refuel policy Manual.
- Save migration per §3.
- **Test:** After accepting the deal, both ships have full tanks and Manual policy. A purchased ship arrives full and on Manual. Landing a ship on Manual changes neither its fuel nor credits. An older save migrates with every ship on Manual and fuel and credits unchanged. Switching a ship to Auto still tops up on landing.
### Step 17.4 — Client: low-fuel warning and policy display
- Fleet cards show a **Low fuel** warning per §2. The ship detail's policy toggle shows Manual as the default.
- **Test:** Component test: a Clipper at Earth with less fuel than its Earth→Mars leg needs shows Low fuel; after refueling, the warning clears. A new ship's detail shows Manual selected.
### Step 17.5 — Plan amendments and regression
- Apply the amendments in §6.
- Run the full suite.
- **Test:** CI is green. The Phase 12 balance script meets all timing targets with the new fuel price and Manual default (the simulated player refuels with "Refuel here first" when a leg needs it). Manual check: on a fresh account, accept the deal, confirm both ships are full and on Manual, fly several Earth–Moon jobs, and confirm credits grow.
## 6. Amendments
| Where | Change |
|---|---|
| Plan 0.5 | Add CR-017.D1–D4; amend CR-014.D5 (default policy). |
| CR-014 §3 | "Auto top-up (default)" becomes "Manual (default)"; base fuel price lowered. |
| CR-014 14.4, 14.6 tests | Default policy is Manual. |
| CR-010 10J.3 | Deal ships are created with full tanks. |
| Plan 12.1 | The simulated player refuels before a leg when needed instead of relying on Auto top-up. |
## 7. Out of scope
Changing how Auto top-up spends money (a cash reserve or partial fills), fuel price drift, refueling notifications, and anything else in `implementation-plan.md` §0.3.

View File

@ -165,6 +165,10 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
107. **Tutorial XP multiplier** (CR-017). Tutorial runs earn normal XP × the tutorial XP multiplier (default 2), so the avatar has at least one skill point at the deal.
108. **Deal location** (CR-017). The deal is available after the fifth run wherever the loaner is; accepting it places the avatar at Earth (replaces 0.5.30's "loaner at Earth" condition).
109. **Skill point nudge** (CR-017). A one-time pop-up after the deal (and after the notification prompt) lists each pilot's unspent points; the Pilots tab badge shows the total unspent points.
110. **Base fuel price** (CR-018). Lowered and tuned so that, for every ship model and route it can fly, a typical loaded leg's fuel (at the origin's price) is 15–25% of the median payout of contracts on that route that fit the ship.
111. **Deal ships full** (CR-018). The deal's Courier and Clipper start with full tanks, valued at Earth's fuel price (replaces the quarter tank chosen during CR-015).
112. **Manual by default** (CR-018). New ships, from the deal or bought, default to Manual refueling (replaces 0.5.97's Auto default); Auto top-up is unchanged and still available per ship. Existing saves move every ship to Manual.
113. **Low-fuel warning** (CR-018). A ship's Fleet card warns "Low fuel" when its tank can't fly its longest reachable leg from where it is (CR-015 §4, CR-016 §3).
### 0.6 Translating change requests written for the server (CR-014 §7)
@ -822,6 +826,12 @@ Steps 17.1–17.7 are specified in `memory-bank/change-requests/CR-017-tutorial-
---
## Phase 18 — Fuel Tuning (CR-018)
Steps 18.1–18.5 are specified in `memory-bank/change-requests/CR-018-fuel-tuning.md` §5 (written there as 17.1–17.5), with its §6 amendments. Stop for review at the end.
---
## Definition of Done (Base Game)
- All step tests pass in CI.

View File

@ -423,3 +423,13 @@ Spec: `memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md` (received
- [x] **Step 17.7 — Plan amendments and regression.** CR-017 §6 applied: plan 0.5.30 (five runs, loaner anywhere) and the 12.3 rewrite note; CR-001 §2's story, its 0.5.22, Steps 10A.1, 10A.3, 10A.7 and 10A.8; CR-011 Step 10H.4 (the second one-time tip). Full suite: typecheck, lint, 1291 unit tests and 9 e2e tests pass. Balance (both strategies, seed 12345): deal 3.7 min (target under 10), first Market purchase 92.9 (45–95), first discovery 49.5, loan paid off 136.1, no violations.
**Phase 17 is complete; stopped for review.** Manual check for the user: on a fresh company, fly five tutorial runs, accept the deal, check the pop-up shows points for both You and Rosa, then spend them from the Pilots tab and watch the tab's badge count down and disappear.
## Phase 18 — Fuel Tuning (CR-018)
Spec: `memory-bank/change-requests/CR-018-fuel-tuning.md` (received as "CR-017 … Phase 17"). New decisions recorded as plan §0.5.110–0.5.113.
- [x] **Step 18.1 — Sim: fuel-to-payout balance check.** `packages/sim/balance/fuel-share.ts`: for every purchasable ship model and every route it can fly, boards at every origin (all locations discovered, that ship as the fleet, 40 periods × 3 worlds), the contracts that fit its hold, and the leg's fuel at the origin's price against their median payout. The balance script prints the table. At the old price (10 cr/fu) the shares ran from 0.7% (Bulk Hauler Jupiter → Moon) to 77.5% (Clipper Mars → Earth); the Courier's Earth → Moon was 20.9%. No single price can put every row in 15–25%, so the user chose to halve the price and gate only rows above 25% (Step 18.2). Tests (`packages/sim/test/fuel-share.test.ts`): a row for every purchasable ship, each on a route it can fly with contracts offered, Earth → Moon for every ship; the Courier's Earth → Moon row is its leg fuel × Earth's price over the median; the table has one line per row.
- [ ] Step 18.2 — Content: lower the base fuel price
- [ ] Step 18.3 — Engine: deal ships start full; Manual by default
- [ ] Step 18.4 — Client: low-fuel warning and policy display
- [ ] Step 18.5 — Plan amendments and regression

View File

@ -0,0 +1,106 @@
import type { GameConfig } from "@deadhead/content";
import { generateContractBoard, type SimContent } from "../src/board";
import { checkRoute } from "../src/eligibility";
import { routeDistance } from "../src/distance";
import { fuelPriceAt, legFuel } from "../src/fuel";
/** One ship model on one route it can fly (decision 0.5.110). */
export interface FuelShare {
modelId: string;
originId: string;
destinationId: string;
/** Fuel for the leg bought at the origin's price, in credits. */
fuelCost: number;
/** Median payout of the contracts on this route that fit the ship. */
medianPayout: number;
/** How many such contracts the boards offered. */
contracts: number;
/** fuelCost ÷ medianPayout. */
share: number;
}
export interface FuelShareOptions {
/** Board periods sampled at every origin, per world. */
periods?: number;
worldSeeds?: readonly number[];
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
}
/**
* The fuel-to-payout check (CR-018 Step 18.1): for every purchasable ship model
* and every route it can fly, the fuel a leg burns (fuel has no mass, so any
* load burns the same) at the origin's price, against the median payout of
* the contracts on that route that fit the ship, over generated boards with
* every location discovered and that ship as the fleet.
*/
export function fuelShares(content: SimContent, options: FuelShareOptions = {}): FuelShare[] {
const { periods = 40, worldSeeds = [12345, 777, 4242] } = options;
const config: GameConfig = content.config;
const discoveredIds = new Set(content.locations.map((l) => l.id));
const rows: FuelShare[] = [];
for (const model of content.shipModels.filter((m) => m.purchasable)) {
const payouts = new Map<string, number[]>();
for (const origin of content.locations) {
for (const worldSeed of worldSeeds) {
for (let periodIndex = 0; periodIndex < periods; periodIndex++) {
const board = generateContractBoard(
{
location: origin,
discoveredIds,
periodIndex,
worldSeed,
fleet: [{ modelId: model.id, skills: {} }],
},
content,
);
for (const contract of board) {
if (contract.massTonnes > model.cargoCapacityTonnes) continue;
const key = `${contract.originId}>${contract.destinationId}`;
payouts.set(key, [...(payouts.get(key) ?? []), contract.payout]);
}
}
}
}
for (const origin of content.locations) {
for (const destination of content.locations) {
if (origin.id === destination.id) continue;
if (!checkRoute(model, origin, destination, config).ok) continue;
const offered = payouts.get(`${origin.id}>${destination.id}`) ?? [];
if (offered.length === 0) continue;
const fuelCost =
legFuel(model, origin, routeDistance(origin, destination), config) *
fuelPriceAt(origin, config);
const medianPayout = median(offered);
rows.push({
modelId: model.id,
originId: origin.id,
destinationId: destination.id,
fuelCost,
medianPayout,
contracts: offered.length,
share: fuelCost / medianPayout,
});
}
}
}
return rows;
}
/** The check as a table, one line per ship and route (for the balance script). */
export function formatFuelShares(rows: readonly FuelShare[]): string {
const lines = rows.map(
(r) =>
`${r.modelId.padEnd(12)} ${`${r.originId} → ${r.destinationId}`.padEnd(50)} ` +
`${Math.round(r.fuelCost).toString().padStart(7)} ${Math.round(r.medianPayout).toString().padStart(8)} ` +
`${(r.share * 100).toFixed(1).padStart(5)}%`,
);
return [
"ship route fuel median share",
...lines,
].join("\n");
}

View File

@ -1,9 +1,12 @@
/// <reference types="node" />
/**
* Prints a balance run for both first-purchase policies (plan Step 12.1):
* Prints a balance run for both first-purchase policies (plan Step 12.1), then
* the fuel-to-payout check (CR-018 Step 18.1):
* pnpm --filter @deadhead/sim balance [worldSeed] [hours]
*/
import { MS_PER_HOUR } from "../src/index";
import { content } from "@deadhead/content";
import { formatFuelShares, fuelShares } from "./fuel-share";
import { simulate, type PurchasePolicy } from "./simulate";
const worldSeed = Number(process.argv[2] ?? 12345);
@ -26,3 +29,6 @@ for (const policy of ["lighter-first", "freighter-first"] as PurchasePolicy[]) {
);
}
}
console.log("\n=== fuel share of median payout, per ship and route (CR-018) ===");
console.log(formatFuelShares(fuelShares(content)));

View File

@ -0,0 +1,54 @@
import { content } from "@deadhead/content";
import { beforeAll, describe, expect, it } from "vitest";
import { formatFuelShares, fuelShares, type FuelShare } from "../balance/fuel-share";
import { checkRoute, fuelPriceAt, legFuel, routeDistance } from "../src/index";
const loc = (id: string) => content.location(id)!;
let rows: FuelShare[];
beforeAll(() => {
rows = fuelShares(content);
});
describe("fuel-to-payout check (CR-018 Step 18.1, decision 0.5.110)", () => {
it("reports every purchasable ship on every route it can fly", () => {
const models = content.shipModels.filter((m) => m.purchasable);
expect(new Set(rows.map((r) => r.modelId))).toEqual(new Set(models.map((m) => m.id)));
for (const row of rows) {
const model = content.shipModel(row.modelId)!;
expect(checkRoute(model, loc(row.originId), loc(row.destinationId), content.config).ok).toBe(
true,
);
expect(row.contracts).toBeGreaterThan(0);
}
// Every Earth–Moon leg is flown by every purchasable ship, and boards always offer it.
for (const model of models) {
expect(
rows.some(
(r) => r.modelId === model.id && r.originId === "earth" && r.destinationId === "moon",
),
).toBe(true);
}
});
it("prices a leg's fuel at the origin and divides by the median fitting payout", () => {
const row = rows.find(
(r) => r.modelId === "courier" && r.originId === "earth" && r.destinationId === "moon",
)!;
const courier = content.shipModel("courier")!;
const fuel =
legFuel(courier, loc("earth"), routeDistance(loc("earth"), loc("moon")), content.config) *
fuelPriceAt(loc("earth"), content.config);
expect(row.fuelCost).toBeCloseTo(fuel, 9);
expect(row.share).toBeCloseTo(row.fuelCost / row.medianPayout, 12);
expect(row.medianPayout).toBeGreaterThan(0);
});
it("reports the share for each ship and route as a table", () => {
const table = formatFuelShares(rows).split("\n");
expect(table).toHaveLength(rows.length + 1);
expect(
table.find((line) => line.startsWith("courier") && line.includes("earth → moon")),
).toMatch(/\d+\.\d%$/);
});
});