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>
This commit is contained in:
fireshaper 2026-10-10 17:22:26 -04:00
parent e5eebdd8aa
commit 77eaa3ac98
5 changed files with 174 additions and 2 deletions

View File

@ -1587,3 +1587,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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. - **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. - **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

@ -428,7 +428,7 @@ Spec: `memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md` (received
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. 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.
- [ ] Step 18.1 — Sim: fuel-to-payout balance check - [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.2 — Content: lower the base fuel price
- [ ] Step 18.3 — Engine: deal ships start full; Manual by default - [ ] Step 18.3 — Engine: deal ships start full; Manual by default
- [ ] Step 18.4 — Client: low-fuel warning and policy display - [ ] Step 18.4 — Client: low-fuel warning and policy display

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" /> /// <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] * pnpm --filter @deadhead/sim balance [worldSeed] [hours]
*/ */
import { MS_PER_HOUR } from "../src/index"; import { MS_PER_HOUR } from "../src/index";
import { content } from "@deadhead/content";
import { formatFuelShares, fuelShares } from "./fuel-share";
import { simulate, type PurchasePolicy } from "./simulate"; import { simulate, type PurchasePolicy } from "./simulate";
const worldSeed = Number(process.argv[2] ?? 12345); 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%$/);
});
});