deadhead-space-freight-tycoon/packages/sim/balance/fuel-share.ts

107 lines
4.0 KiB
TypeScript

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