110 lines
4.3 KiB
TypeScript
110 lines
4.3 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, with a pilot skilled in everything,
|
|
* as the fleet (boards keep only jobs the fleet can complete, CR-019).
|
|
*/
|
|
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));
|
|
// A pilot with every skill maxed: only the ship limits which jobs fit (CR-019 filters boards by the fleet).
|
|
const skills = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel]));
|
|
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");
|
|
}
|