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(); 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"); }