839 lines
29 KiB
TypeScript
839 lines
29 KiB
TypeScript
/**
|
||
* Balance simulation (plan Step 12.1, amended by CR-001 to CR-013): a simulated
|
||
* player plays from "Start your company" using only the sim and content, the
|
||
* way the server applies them, and the run records when each milestone was
|
||
* reached plus any breach of the game's invariants.
|
||
*
|
||
* The player is always active: it acts the moment something is possible, so
|
||
* times are "real time, active play". Pure and deterministic: same options,
|
||
* same result.
|
||
*/
|
||
import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
||
import {
|
||
affordableRefuel,
|
||
emergencyFuel,
|
||
boardPeriodIndex,
|
||
fuelPriceAt,
|
||
type Tank,
|
||
fullTankReach,
|
||
boardPeriodMs,
|
||
checkRoute,
|
||
completedGoals,
|
||
contractTripGameHours,
|
||
createDispatchRecord,
|
||
createRepositionRecord,
|
||
effectiveStats,
|
||
generateContractBoard,
|
||
generateTutorialBoard,
|
||
isCompletable,
|
||
isResting,
|
||
loanerModel,
|
||
MS_PER_HOUR,
|
||
MS_PER_MINUTE,
|
||
nextUpgradeLevel,
|
||
pilotLevel,
|
||
pilotShare,
|
||
qualifyingFleet,
|
||
resolveDispatch,
|
||
restEndsAt,
|
||
skillPointsAvailable,
|
||
tripFuel,
|
||
validateDispatch,
|
||
validateReposition,
|
||
validateUpgrade,
|
||
type Contract,
|
||
type DispatchRecord,
|
||
type GoalProgress,
|
||
type PilotState,
|
||
type RealTime,
|
||
type ShipState,
|
||
type SimContent,
|
||
type UpgradeLevels,
|
||
} from "../src/index";
|
||
|
||
type Content = SimContent & {
|
||
onboardingGoals: typeof defaultContent.onboardingGoals;
|
||
veteranPilot: typeof defaultContent.veteranPilot;
|
||
dealLoanAmount: number;
|
||
};
|
||
|
||
/** The first two ships the player buys from the Market, in order (CR-013). */
|
||
export type PurchasePolicy = "lighter-first" | "freighter-first";
|
||
|
||
export interface SimulationOptions {
|
||
worldSeed: number;
|
||
policy: PurchasePolicy;
|
||
/** Real time to simulate from the start, in ms. */
|
||
durationMs: number;
|
||
/** When to stop early: every milestone reached. Default true. */
|
||
stopWhenDone?: boolean;
|
||
content?: Content;
|
||
/** Called with one line per event, for tracing a run. */
|
||
log?: (line: string) => void;
|
||
}
|
||
|
||
export interface Sample {
|
||
/** Real minutes since the start. */
|
||
minute: number;
|
||
credits: number;
|
||
loanBalance: number;
|
||
ships: number;
|
||
/** Level of each pilot, in hiring order (avatar first). */
|
||
pilotLevels: number[];
|
||
}
|
||
|
||
/** Real minutes from the start until each milestone; null if never reached. */
|
||
export interface Milestones {
|
||
dealAccepted: number | null;
|
||
firstMarketPurchase: number | null;
|
||
firstDiscovery: number | null;
|
||
loanPaidOff: number | null;
|
||
ownFourShips: number | null;
|
||
}
|
||
|
||
export interface SimulationResult {
|
||
policy: PurchasePolicy;
|
||
milestones: Milestones;
|
||
/** Every 10 real minutes. */
|
||
samples: Sample[];
|
||
tutorialRuns: number;
|
||
/** The player's tutorial share over all tutorial runs (Step 12.2). */
|
||
tutorialShareEarned: number;
|
||
/** Credits on hand the moment the deal is accepted. */
|
||
creditsAtDeal: number | null;
|
||
contractsDelivered: number;
|
||
/** Contracts the veteran delivered in the first two hours (CR-010 check). */
|
||
veteranContractsFirstSession: number;
|
||
/** Invariant breaches, each with the real minute it happened. */
|
||
violations: { minute: number; rule: string; detail: string }[];
|
||
/** Ships bought, in order. */
|
||
purchases: string[];
|
||
/** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */
|
||
jobsShown: number;
|
||
uncompletableShown: number;
|
||
}
|
||
|
||
interface SimShip extends ShipState {
|
||
/** Every simulated ship has a tank, on Auto top-up (decision 0.5.97). */
|
||
tank: Tank;
|
||
upgrades: UpgradeLevels;
|
||
inShopUntil: RealTime | null;
|
||
fromDeal: boolean;
|
||
trips: number;
|
||
}
|
||
|
||
interface SimPilot {
|
||
id: string;
|
||
isAvatar: boolean;
|
||
isVeteran: boolean;
|
||
xp: number;
|
||
skills: Record<string, number>;
|
||
spent: number;
|
||
assignedShipId: string | null;
|
||
dutyHours: number;
|
||
restEndsAt: RealTime | null;
|
||
inFlight: boolean;
|
||
}
|
||
|
||
interface Flight {
|
||
record: DispatchRecord;
|
||
shipId: string | null;
|
||
pilotId: string;
|
||
}
|
||
|
||
const PURCHASE_ORDER: Record<PurchasePolicy, string[]> = {
|
||
"lighter-first": ["lighter", "freighter"],
|
||
"freighter-first": ["freighter", "lighter"],
|
||
};
|
||
const FIRST_SESSION_MS = 2 * 60 * MS_PER_MINUTE;
|
||
const SAMPLE_EVERY_MS = 10 * MS_PER_MINUTE;
|
||
/**
|
||
* Cash kept back for fuel after buying a ship, so the fleet can keep flying:
|
||
* at least this, and at least what filling every landed ship costs where it
|
||
* is (ships refuel by hand since CR-018).
|
||
*/
|
||
const FUEL_RESERVE = 5_000;
|
||
/** Upgrades only from spare cash: never more than this share of credits on hand. */
|
||
const UPGRADE_CASH_SHARE = 0.1;
|
||
|
||
export function simulate(options: SimulationOptions): SimulationResult {
|
||
const content = options.content ?? (defaultContent as Content);
|
||
const { config } = content;
|
||
const start = Date.parse(config.epoch.realStart);
|
||
let now: RealTime = start;
|
||
const minute = (t: RealTime = now) => Math.round(((t - start) / MS_PER_MINUTE) * 10) / 10;
|
||
const purchaseOrder = PURCHASE_ORDER[options.policy];
|
||
|
||
// --- State, as a new guest has it (server new-player). ---
|
||
let credits = config.startingCredits;
|
||
let loanBalance = 0;
|
||
let tutorialActive = true;
|
||
let runsCompleted = 0;
|
||
let loanerLocationId: "earth" | "moon" = "earth";
|
||
const discovered = new Set(content.locations.filter((l) => l.startsDiscovered).map((l) => l.id));
|
||
const bays = new Set<string>();
|
||
const ships: SimShip[] = [];
|
||
const pilots: SimPilot[] = [
|
||
{
|
||
id: "avatar",
|
||
isAvatar: true,
|
||
isVeteran: false,
|
||
xp: 0,
|
||
skills: {},
|
||
spent: 0,
|
||
assignedShipId: null,
|
||
dutyHours: 0,
|
||
restEndsAt: null,
|
||
inFlight: false,
|
||
},
|
||
];
|
||
const flights: Flight[] = [];
|
||
const accepted = new Set<string>();
|
||
const boards = new Map<string, Contract[]>();
|
||
const claimed = new Set<string>();
|
||
let nextId = 0;
|
||
const id = (prefix: string) => `${prefix}-${++nextId}`;
|
||
|
||
const milestones: Milestones = {
|
||
dealAccepted: null,
|
||
firstMarketPurchase: null,
|
||
firstDiscovery: null,
|
||
loanPaidOff: null,
|
||
ownFourShips: null,
|
||
};
|
||
const samples: Sample[] = [];
|
||
const violations: SimulationResult["violations"] = [];
|
||
const purchases: string[] = [];
|
||
let contractsDelivered = 0;
|
||
let tutorialShareEarned = 0;
|
||
let creditsAtDeal: number | null = null;
|
||
let priorityDeliveries = 0;
|
||
let veteranContractsFirstSession = 0;
|
||
const seenViolations = new Set<string>();
|
||
/** Records a breach once per rule, subject and board period. */
|
||
const violate = (rule: string, detail: string) => {
|
||
const key = `${rule}:${detail}:${boardPeriodIndex(now, config)}`;
|
||
if (seenViolations.has(key)) return;
|
||
seenViolations.add(key);
|
||
violations.push({ minute: minute(), rule, detail });
|
||
};
|
||
|
||
const model = (modelId: string): ShipModel => content.shipModels.find((m) => m.id === modelId)!;
|
||
const loc = (locationId: string) => content.locations.find((l) => l.id === locationId)!;
|
||
const pilotOf = (ship: SimShip) => pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||
const pilotState = (p: SimPilot): PilotState => ({
|
||
id: p.id,
|
||
isAvatar: p.isAvatar,
|
||
assignedShipId: p.assignedShipId,
|
||
inFlight: p.inFlight,
|
||
skills: p.skills,
|
||
restEndsAt: p.restEndsAt,
|
||
});
|
||
/** The qualifying fleet for a location's board (decision 0.5.115), as the engine builds it. */
|
||
const fleetFor = (locationId: string, periodEndsAt: number) =>
|
||
qualifyingFleet({
|
||
locationId,
|
||
ships: ships.map((s) => {
|
||
const flight = s.inFlight ? flights.find((f) => f.shipId === s.id) : undefined;
|
||
return {
|
||
id: s.id,
|
||
modelId: s.modelId,
|
||
upgrades: s.upgrades,
|
||
fuelFu: s.tank.fuelFu,
|
||
locationId: s.inFlight ? null : s.locationId,
|
||
destinationId: flight?.record.destinationId ?? null,
|
||
arrivalAt: flight?.record.arrivalAt ?? null,
|
||
};
|
||
}),
|
||
// Every simulated pilot is hired onto a ship, so none waits at a bay.
|
||
pilots: pilots.map((p) => ({
|
||
id: p.id,
|
||
skills: p.skills,
|
||
assignedShipId: p.assignedShipId,
|
||
locationId: null,
|
||
})),
|
||
bayLocationIds: bays,
|
||
periodEndsAt,
|
||
}).members;
|
||
let jobsShown = 0;
|
||
let uncompletableShown = 0;
|
||
|
||
/**
|
||
* The location board for the current period, generated once per period for
|
||
* the qualifying fleet, and read as the engine reads it: jobs the fleet can
|
||
* no longer complete are hidden (CR-019).
|
||
*/
|
||
function board(locationId: string): Contract[] {
|
||
const period = boardPeriodIndex(now, config);
|
||
const key = `${locationId}:${period}`;
|
||
const fleet = fleetFor(locationId, (period + 1) * boardPeriodMs(config));
|
||
let contracts = boards.get(key);
|
||
if (!contracts) {
|
||
contracts = generateContractBoard(
|
||
{
|
||
location: loc(locationId),
|
||
discoveredIds: discovered,
|
||
periodIndex: period,
|
||
worldSeed: options.worldSeed,
|
||
fleet,
|
||
},
|
||
content,
|
||
);
|
||
boards.set(key, contracts);
|
||
// Fresh from the generator, every job must be one this fleet can complete.
|
||
jobsShown += contracts.length;
|
||
uncompletableShown += contracts.filter(
|
||
(c) => !isCompletable(c, fleet, discovered, content),
|
||
).length;
|
||
}
|
||
return contracts.filter(
|
||
(c) => !accepted.has(c.id) && isCompletable(c, fleet, discovered, content),
|
||
);
|
||
}
|
||
|
||
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
|
||
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
|
||
const origin = loc(contract.originId);
|
||
const fuel =
|
||
tripFuel(stats, origin, loc(contract.destinationId), config) * fuelPriceAt(origin, config);
|
||
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
|
||
const tripMs =
|
||
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
|
||
return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE);
|
||
}
|
||
|
||
/** A ship as it would be with a full tank (for "is there any way out" checks). */
|
||
function filled(ship: SimShip): SimShip {
|
||
const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu;
|
||
return { ...ship, tank: { ...ship.tank, fuelFu: capacity } };
|
||
}
|
||
|
||
/** Contracts this crew may take now, with the fuel on board (or a full tank). */
|
||
function eligibleContracts(ship: SimShip, pilot: SimPilot, fullTank = false): Contract[] {
|
||
if (!discovered.has(ship.locationId)) return [];
|
||
return board(ship.locationId).filter(
|
||
(contract) =>
|
||
validateDispatch(
|
||
{
|
||
ship: fullTank ? filled(ship) : ship,
|
||
pilot: pilotState(pilot),
|
||
contract,
|
||
tutorial: false,
|
||
now,
|
||
discoveredIds: discovered,
|
||
},
|
||
content,
|
||
).ok,
|
||
);
|
||
}
|
||
|
||
const log = (line: string) => options.log?.(`${String(minute()).padStart(6)} ${line}`);
|
||
|
||
function dispatch(ship: SimShip, pilot: SimPilot, contract: Contract) {
|
||
const record = createDispatchRecord(
|
||
{
|
||
id: id("d"),
|
||
ship,
|
||
pilot: pilotState(pilot),
|
||
contract,
|
||
tutorial: false,
|
||
now,
|
||
worldSeed: options.worldSeed,
|
||
},
|
||
content,
|
||
);
|
||
if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id);
|
||
accepted.add(contract.id);
|
||
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
|
||
log(
|
||
`${ship.modelId} ${contract.originId}→${contract.destinationId} ${contract.goodId} ${contract.massTonnes}t pays ${contract.payout}${contract.firstContact ? " FIRST CONTACT" : ""} fuel ${record.fuelCost} arrives +${Math.round((record.arrivalAt - now) / MS_PER_MINUTE)}m credits ${credits}`,
|
||
);
|
||
ship.inFlight = true;
|
||
ship.trips++;
|
||
pilot.inFlight = true;
|
||
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
|
||
}
|
||
|
||
// --- Arrivals (server resolve.ts). ---
|
||
function resolveArrivals() {
|
||
flights.sort((a, b) => a.record.arrivalAt - b.record.arrivalAt);
|
||
while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) {
|
||
const flight = flights.shift()!;
|
||
const pilot = pilots.find((p) => p.id === flight.pilotId)!;
|
||
const flightShip = flight.shipId ? ships.find((s) => s.id === flight.shipId)! : undefined;
|
||
const result = resolveDispatch(
|
||
{
|
||
record: flight.record,
|
||
pilotIsAvatar: pilot.isAvatar,
|
||
discoveredIds: discovered,
|
||
loanBalance,
|
||
pilotDutyHours: pilot.dutyHours,
|
||
pilotSkills: pilot.skills,
|
||
now,
|
||
fuel: flightShip
|
||
? {
|
||
tank: flightShip.tank,
|
||
capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content)
|
||
.tankCapacityFu,
|
||
// New ships are on Manual (CR-018, decision 0.5.112).
|
||
refuelPolicy: "manual",
|
||
credits,
|
||
}
|
||
: undefined,
|
||
},
|
||
content,
|
||
);
|
||
if (result.status !== "resolved") throw new Error("arrived flight did not resolve");
|
||
const r = result.resolution;
|
||
credits += r.credits;
|
||
loanBalance -= r.loanRepayment;
|
||
if (r.topUp && flightShip) {
|
||
credits -= r.topUp.cost;
|
||
flightShip.tank = r.topUp.tank;
|
||
}
|
||
pilot.xp += r.pilotXp;
|
||
pilot.inFlight = false;
|
||
pilot.dutyHours = r.pilotDutyHours;
|
||
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
|
||
if (flight.shipId) {
|
||
const ship = ships.find((s) => s.id === flight.shipId)!;
|
||
ship.inFlight = false;
|
||
ship.locationId = r.shipLocationId;
|
||
}
|
||
if (flight.record.tutorial) {
|
||
runsCompleted++;
|
||
tutorialShareEarned += r.credits;
|
||
loanerLocationId = r.shipLocationId as "earth" | "moon";
|
||
} else if (flight.record.contract) {
|
||
contractsDelivered++;
|
||
if (flight.record.contract.priority) priorityDeliveries++;
|
||
if (pilot.isVeteran && flight.record.arrivalAt - start <= FIRST_SESSION_MS) {
|
||
veteranContractsFirstSession++;
|
||
}
|
||
}
|
||
if (flight.record.contract && !flight.record.tutorial) {
|
||
log(
|
||
` arrived ${flight.record.destinationId}: +${r.credits} (repaid ${r.loanRepayment}, pilot ${r.pilotPay}) credits ${credits}`,
|
||
);
|
||
}
|
||
if (r.discoversDestination) {
|
||
discovered.add(flight.record.destinationId);
|
||
milestones.firstDiscovery ??= minute(flight.record.arrivalAt);
|
||
}
|
||
if (!tutorialActive && loanBalance <= 0)
|
||
milestones.loanPaidOff ??= minute(flight.record.arrivalAt);
|
||
}
|
||
}
|
||
|
||
// --- Tutorial and the deal (server dispatch.ts, deal.ts). ---
|
||
function playTutorial(): boolean {
|
||
if (flights.some((f) => f.record.tutorial)) return false;
|
||
// Wherever the loaner is (decision 0.5.108).
|
||
if (runsCompleted >= config.tutorialRunsBeforeDeal) {
|
||
acceptDeal();
|
||
return true;
|
||
}
|
||
const contracts = generateTutorialBoard(
|
||
{ loanerLocationId, runsCompleted, periodIndex: boardPeriodIndex(now, config) },
|
||
content,
|
||
).filter((c) => !accepted.has(c.id));
|
||
const loaner: ShipState = {
|
||
id: "loaner",
|
||
modelId: loanerModel(content).id,
|
||
locationId: loanerLocationId,
|
||
inFlight: false,
|
||
};
|
||
const avatar = pilots[0]!;
|
||
// Tutorial runs use no fuel; the player keeps the tutorial share: best share per minute.
|
||
const best = contracts
|
||
.filter(
|
||
(c) =>
|
||
validateDispatch(
|
||
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, now },
|
||
content,
|
||
).ok,
|
||
)
|
||
.map((c) => {
|
||
const record = createDispatchRecord(
|
||
{
|
||
id: id("t"),
|
||
ship: loaner,
|
||
pilot: pilotState(avatar),
|
||
contract: c,
|
||
tutorial: true,
|
||
now,
|
||
worldSeed: options.worldSeed,
|
||
},
|
||
content,
|
||
);
|
||
return { record, value: c.payout / (record.arrivalAt - now) };
|
||
})
|
||
.sort((a, b) => b.value - a.value)[0];
|
||
if (!best) return false;
|
||
accepted.add(best.record.contract!.id);
|
||
avatar.inFlight = true;
|
||
flights.push({ record: best.record, shipId: null, pilotId: avatar.id });
|
||
return true;
|
||
}
|
||
|
||
function acceptDeal() {
|
||
const { avatarShipModelId, veteranShipModelId, bayLocationId } = config.deal;
|
||
const courier = newShip(avatarShipModelId, bayLocationId, true);
|
||
const clipper = newShip(veteranShipModelId, bayLocationId, true);
|
||
bays.add(bayLocationId);
|
||
pilots[0]!.assignedShipId = courier.id;
|
||
const veteran = content.veteranPilot;
|
||
pilots.push({
|
||
id: "veteran",
|
||
isAvatar: false,
|
||
isVeteran: true,
|
||
xp: veteran.level === 0 ? 0 : config.pilotLevelXpThresholds[veteran.level - 1]!,
|
||
skills: { ...veteran.skills },
|
||
spent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
|
||
assignedShipId: clipper.id,
|
||
dutyHours: 0,
|
||
restEndsAt: null,
|
||
inFlight: false,
|
||
});
|
||
loanBalance = content.dealLoanAmount;
|
||
tutorialActive = false;
|
||
creditsAtDeal = credits;
|
||
milestones.dealAccepted = minute();
|
||
}
|
||
|
||
function newShip(modelId: string, locationId: string, fromDeal: boolean): SimShip {
|
||
const ship: SimShip = {
|
||
id: id("s"),
|
||
modelId,
|
||
locationId,
|
||
inFlight: false,
|
||
tank: {
|
||
// Market ships and the deal's ships come full (CR-015, decision 0.5.111).
|
||
fuelFu: model(modelId).tankCapacityFu,
|
||
avgPrice: fuelPriceAt(loc(locationId), config),
|
||
},
|
||
upgrades: {},
|
||
inShopUntil: null,
|
||
fromDeal,
|
||
trips: 0,
|
||
};
|
||
ships.push(ship);
|
||
return ship;
|
||
}
|
||
|
||
// --- Owner decisions. ---
|
||
function claimGoals(): boolean {
|
||
const progress: GoalProgress = {
|
||
ownShipDeliveries: contractsDelivered,
|
||
skillPointsSpent: pilots.reduce((n, p) => n + p.spent, 0),
|
||
shipsOwned: ships.length,
|
||
marketShipsBought: ships.filter((s) => !s.fromDeal).length,
|
||
pilotsHired: pilots.filter((p) => !p.isAvatar).length,
|
||
priorityDeliveries,
|
||
locationsDiscovered: [...discovered].filter((d) => !loc(d).startsDiscovered).length,
|
||
dealAccepted: !tutorialActive,
|
||
loanBalance,
|
||
};
|
||
let any = false;
|
||
for (const goalId of completedGoals(content.onboardingGoals, progress)) {
|
||
if (claimed.has(goalId)) continue;
|
||
claimed.add(goalId);
|
||
credits += content.onboardingGoals.find((g) => g.id === goalId)!.reward;
|
||
any = true;
|
||
}
|
||
return any;
|
||
}
|
||
|
||
/**
|
||
* Skill points (owner's choice): Long Haul when the pilot's ship can fly
|
||
* further than the first Long Haul cut-off and Long Haul is behind; otherwise
|
||
* the cargo skill that suits the ship (big holds: Heavy; small, fast: Expensive).
|
||
*/
|
||
function spendSkillPoints() {
|
||
for (const pilot of pilots) {
|
||
const ship = ships.find((s) => s.id === pilot.assignedShipId);
|
||
if (!ship) continue;
|
||
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
|
||
while (skillPointsAvailable(pilotLevel(pilot.xp, config), pilot.spent, config) > 0) {
|
||
const cargoSkill = stats.cargoCapacityTonnes >= 60 ? "heavy-cargo" : "expensive-cargo";
|
||
const longHaul = pilot.skills["long-haul"] ?? 0;
|
||
const cargo = pilot.skills[cargoSkill] ?? 0;
|
||
const wantsLongHaul =
|
||
fullTankReach(stats, loc("earth"), config) > config.longHaulCutoffsMillionKm[0]! &&
|
||
longHaul <= cargo;
|
||
const skill = wantsLongHaul ? "long-haul" : cargoSkill;
|
||
pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1;
|
||
pilot.spent++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Cash to keep for fuel: filling every landed ship where it is, and never under FUEL_RESERVE. */
|
||
function fuelReserve(): number {
|
||
const toFill = ships
|
||
.filter((s) => !s.inFlight && loc(s.locationId).sellsFuel)
|
||
.reduce((sum, s) => {
|
||
const capacity = effectiveStats(model(s.modelId), s.upgrades, content).tankCapacityFu;
|
||
return sum + (capacity - s.tank.fuelFu) * fuelPriceAt(loc(s.locationId), config);
|
||
}, 0);
|
||
return Math.max(FUEL_RESERVE, Math.ceil(toFill));
|
||
}
|
||
|
||
/** The next planned Market ship, bought at the Earth bay with a pilot hired for it. */
|
||
function buyShips(): boolean {
|
||
const bought = ships.filter((s) => !s.fromDeal).length;
|
||
const next = purchaseOrder[bought];
|
||
if (!next) return false;
|
||
const price = model(next).price + config.pilotHireCost;
|
||
if (credits < price + fuelReserve()) return false;
|
||
credits -= price;
|
||
const ship = newShip(next, config.deal.bayLocationId, false);
|
||
pilots.push({
|
||
id: id("p"),
|
||
isAvatar: false,
|
||
isVeteran: false,
|
||
xp: 0,
|
||
skills: {},
|
||
spent: 0,
|
||
assignedShipId: ship.id,
|
||
dutyHours: 0,
|
||
restEndsAt: null,
|
||
inFlight: false,
|
||
});
|
||
purchases.push(next);
|
||
log(`BUY ${next} credits ${credits}`);
|
||
milestones.firstMarketPurchase ??= minute();
|
||
if (ships.length >= 4) milestones.ownFourShips ??= minute();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Upgrades (CR-009 amendment): cargo bay, then engines, on the most-used ship
|
||
* idle at an HQ, paid only from spare cash so saving for ships isn't stalled.
|
||
*/
|
||
function buyUpgrade(): boolean {
|
||
const candidates = ships
|
||
.filter((s) => !s.inFlight && bays.has(s.locationId))
|
||
.sort((a, b) => b.trips - a.trips);
|
||
const ship = candidates[0];
|
||
if (!ship) return false;
|
||
for (const upgradeId of ["cargo-bay", "engines"]) {
|
||
const upgrade = content.upgrades.find((u) => u.id === upgradeId)!;
|
||
if (nextUpgradeLevel(upgrade, ship.modelId, ship.upgrades) === null) continue;
|
||
const check = validateUpgrade(
|
||
{ ship, upgrade, atHq: true, tutorialActive, credits, now },
|
||
content,
|
||
);
|
||
if (!check.ok || check.cost > credits * UPGRADE_CASH_SHARE) continue;
|
||
credits -= check.cost;
|
||
ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level };
|
||
ship.inShopUntil = check.shopUntil;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Fly every free ship: best contract here, else a deadhead to work, else rest the pilot. */
|
||
function flyShips(): boolean {
|
||
let acted = false;
|
||
for (const ship of ships) {
|
||
const pilot = pilotOf(ship);
|
||
if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue;
|
||
if (ship.inShopUntil !== null && ship.inShopUntil > now) continue;
|
||
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
|
||
// CR-015 §7 (Step 15.9): with money in the bank, no ship should ever be
|
||
// stuck needing emergency fuel.
|
||
if (
|
||
credits > 0 &&
|
||
emergencyFuel({ ship, credits, discoveredIds: discovered }, content) !== null
|
||
) {
|
||
violate("needs-emergency-fuel", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
|
||
}
|
||
const best = (candidate: SimShip) =>
|
||
eligibleContracts(candidate, pilot)
|
||
.map((c) => ({ c, value: rate(c, stats, pilot) }))
|
||
.filter((o) => o.value > 0)
|
||
.sort((a, b) => b.value - a.value)[0];
|
||
// Manual refueling (CR-018): the player taps "Refuel here first" only
|
||
// when the best job needs more fuel than is on board.
|
||
let choice = best(ship);
|
||
const topped = toppedUp(ship, stats.tankCapacityFu);
|
||
const better = topped && best(topped.ship);
|
||
if (topped && better && (!choice || better.value > choice.value)) {
|
||
credits -= topped.cost;
|
||
ship.tank = topped.ship.tank;
|
||
choice = better;
|
||
}
|
||
if (choice) {
|
||
dispatch(ship, pilot, choice.c);
|
||
acted = true;
|
||
continue;
|
||
}
|
||
// No job here: fuel up to go looking for one.
|
||
topUpHere(ship, stats.tankCapacityFu);
|
||
const canDeadhead = (to: string, fullTank: boolean) =>
|
||
to !== ship.locationId &&
|
||
validateReposition(
|
||
{
|
||
ship: fullTank ? filled(ship) : ship,
|
||
pilot: pilotState(pilot),
|
||
destinationId: to,
|
||
discoveredIds: discovered,
|
||
now,
|
||
},
|
||
content,
|
||
).ok;
|
||
const deadheads = [...discovered].filter((to) => canDeadhead(to, false));
|
||
// A dead end is no work and no way out even on a full tank (CR-002);
|
||
// running short of cash for fuel is the soft-lock check's business.
|
||
if (
|
||
eligibleContracts(ship, pilot, true).length === 0 &&
|
||
![...discovered].some((to) => canDeadhead(to, true))
|
||
) {
|
||
violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
|
||
}
|
||
// Deadhead only to a place with paying work for this ship right now.
|
||
const target = deadheads
|
||
.map((to) => {
|
||
const best = board(to)
|
||
.filter((c) => checkRoute(stats, loc(to), loc(c.destinationId), content.config).ok)
|
||
.map((c) => c.payout)
|
||
.sort((a, b) => b - a)[0];
|
||
return { to, best: best ?? 0 };
|
||
})
|
||
.filter((t) => t.best > 0)
|
||
.sort((a, b) => b.best - a.best)[0];
|
||
if (target) {
|
||
const record = createRepositionRecord(
|
||
{
|
||
id: id("r"),
|
||
ship,
|
||
pilot: pilotState(pilot),
|
||
destinationId: target.to,
|
||
now,
|
||
worldSeed: options.worldSeed,
|
||
},
|
||
content,
|
||
);
|
||
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
|
||
ship.inFlight = true;
|
||
pilot.inFlight = true;
|
||
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
|
||
log(
|
||
`${ship.modelId} deadhead ${ship.locationId}→${target.to} fuel ${record.fuelCost} credits ${credits}`,
|
||
);
|
||
acted = true;
|
||
} else if (pilot.dutyHours > 0) {
|
||
// Nothing worth flying: rest now so the pilot is fresh for the next board (CR-004).
|
||
pilot.dutyHours = 0;
|
||
pilot.restEndsAt = restEndsAt(now, config);
|
||
log(`${ship.modelId} pilot rests at ${ship.locationId}`);
|
||
acted = true;
|
||
}
|
||
}
|
||
return acted;
|
||
}
|
||
|
||
/** The ship with its tank topped up where it is, as far as credits go, and the cost; null if nothing to buy. */
|
||
function toppedUp(ship: SimShip, capacityFu: number): { ship: SimShip; cost: number } | null {
|
||
const location = loc(ship.locationId);
|
||
if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return null;
|
||
const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits);
|
||
if (bought.boughtFu <= 0) return null;
|
||
return { ship: { ...ship, tank: bought.tank }, cost: bought.cost };
|
||
}
|
||
|
||
/** Tops the tank up where the ship is, as far as credits go (the player's "Refuel here first"). */
|
||
function topUpHere(ship: SimShip, capacityFu: number) {
|
||
const topped = toppedUp(ship, capacityFu);
|
||
if (!topped) return;
|
||
credits -= topped.cost;
|
||
ship.tank = topped.ship.tank;
|
||
}
|
||
|
||
function checkCashflow() {
|
||
if (tutorialActive || flights.length > 0) return;
|
||
const affordable = ships.some((ship) => {
|
||
const pilot = pilotOf(ship);
|
||
return (
|
||
pilot !== null &&
|
||
discovered.has(ship.locationId) &&
|
||
board(ship.locationId).some(
|
||
(c) =>
|
||
tripFuel(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
|
||
ship.tank.fuelFu + credits / fuelPriceAt(loc(ship.locationId), config),
|
||
)
|
||
);
|
||
});
|
||
if (!affordable) violate("soft-lock", `no flight and no affordable fuel with ${credits} cr`);
|
||
}
|
||
|
||
function sample() {
|
||
samples.push({
|
||
minute: minute(),
|
||
credits,
|
||
loanBalance,
|
||
ships: ships.length,
|
||
pilotLevels: pilots.map((p) => pilotLevel(p.xp, config)),
|
||
});
|
||
}
|
||
|
||
/** The next moment anything can change. */
|
||
function nextEvent(): RealTime {
|
||
const period = boardPeriodMs(config);
|
||
const times = [
|
||
(boardPeriodIndex(now, config) + 1) * period,
|
||
...flights.map((f) => f.record.arrivalAt),
|
||
...pilots.flatMap((p) => (p.restEndsAt !== null && p.restEndsAt > now ? [p.restEndsAt] : [])),
|
||
...ships.flatMap((s) =>
|
||
s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [],
|
||
),
|
||
];
|
||
return Math.min(...times);
|
||
}
|
||
|
||
// --- Main loop. ---
|
||
const end = start + options.durationMs;
|
||
let nextSample = start;
|
||
while (now <= end) {
|
||
resolveArrivals();
|
||
for (let guard = 0; guard < 50; guard++) {
|
||
let acted = false;
|
||
if (tutorialActive) acted = playTutorial() || acted;
|
||
else {
|
||
acted = claimGoals() || acted;
|
||
spendSkillPoints();
|
||
acted = buyShips() || acted;
|
||
if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) {
|
||
acted = buyUpgrade() || acted;
|
||
}
|
||
acted = flyShips() || acted;
|
||
}
|
||
if (!acted) break;
|
||
}
|
||
checkCashflow();
|
||
while (nextSample <= now) {
|
||
sample();
|
||
nextSample += SAMPLE_EVERY_MS;
|
||
}
|
||
const done = Object.values(milestones).every((m) => m !== null);
|
||
if (done && (options.stopWhenDone ?? true)) break;
|
||
now = Math.max(now + 1, nextEvent());
|
||
}
|
||
|
||
return {
|
||
policy: options.policy,
|
||
milestones,
|
||
samples,
|
||
tutorialRuns: runsCompleted,
|
||
tutorialShareEarned,
|
||
creditsAtDeal,
|
||
contractsDelivered,
|
||
veteranContractsFirstSession,
|
||
violations,
|
||
purchases,
|
||
jobsShown,
|
||
uncompletableShown,
|
||
};
|
||
}
|