/** * 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, boardPeriodIndex, fuelPriceAt, type Tank, fullTankReach, boardPeriodMs, checkRoute, completedGoals, contractTripGameHours, createDispatchRecord, createRepositionRecord, effectiveStats, generateContractBoard, generateTutorialBoard, isResting, loanerModel, MS_PER_HOUR, MS_PER_MINUTE, nextUpgradeLevel, pilotLevel, pilotShare, 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[]; } 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; 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 = { "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. */ 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(); 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(); const boards = new Map(); const claimed = new Set(); 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(); /** 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, }); const fleet = () => ships.map((s) => ({ modelId: s.modelId, skills: pilotOf(s)?.skills ?? {}, upgrades: s.upgrades, })); /** The location board for the current period, generated once per period (as the server stores it). */ function board(locationId: string): Contract[] { const period = boardPeriodIndex(now, config); const key = `${locationId}:${period}`; let contracts = boards.get(key); if (!contracts) { contracts = generateContractBoard( { location: loc(locationId), discoveredIds: discovered, periodIndex: period, worldSeed: options.worldSeed, fleet: fleet(), }, content, ); boards.set(key, contracts); } return contracts.filter((c) => !accepted.has(c.id)); } /** 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, refuelPolicy: "auto", 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; if (runsCompleted >= config.tutorialRunsBeforeDeal && loanerLocationId === "earth") { 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 come full; the deal's ships with a quarter tank (CR-015). fuelFu: model(modelId).tankCapacityFu * (fromDeal ? config.deal.tankShare : 1), 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++; } } } /** 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 + FUEL_RESERVE) 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); topUpHere(ship, stats.tankCapacityFu); const choices = eligibleContracts(ship, pilot) .map((c) => ({ c, value: rate(c, stats, pilot) })) .filter((o) => o.value > 0) .sort((a, b) => b.value - a.value); if (choices[0]) { dispatch(ship, pilot, choices[0].c); acted = true; continue; } 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; } /** 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 location = loc(ship.locationId); if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return; const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits); if (bought.boughtFu <= 0) return; credits -= bought.cost; ship.tank = bought.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, }; }