import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { boardPeriodIndex, boardPeriodMs, chooseAction, isInShop, isResting, MS_PER_MINUTE, type RealTime, } from "@deadhead/sim"; import type { RandomSource } from "../core"; import { locationBoard } from "./boards"; import { dispatchContract, dispatchReposition } from "./dispatch"; import { refuelShip } from "./fleet"; import { scheduleDecision } from "./independent"; import { resolveArrivals } from "./resolve"; import { discoveredIds, pilotState, shipState } from "./state"; type SavedPilot = SaveDocument["pilots"][number]; const DAY_MS = 24 * 60 * MS_PER_MINUTE; /** When the current board period's first-look window ends (decision 0.5.126). */ function firstLookEnd(t: RealTime): RealTime { const period = boardPeriodMs(content.config); return ( boardPeriodIndex(t, content.config) * period + content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE ); } /** The next board period's first-look end: when a waiting pilot looks again. */ function nextBoardLook(t: RealTime): RealTime { return firstLookEnd(t) + boardPeriodMs(content.config); } /** * Ids for what pilots do during catch-up come from the pilot and the moment, * not chance, so replaying the same time gives the same save (decision 0.5.129). */ function replayRandom(pilotId: string, t: RealTime): RandomSource { let n = 0; return { id: () => `ind-${pilotId}-${t}-${n++}`, seed: () => 0 }; } export interface CatchUpOptions { /** Pilots decide only up to this time (the 7-day cap); arrivals always resolve. */ decideUntil: RealTime; /** Stop after this many events (a chunk); the rest waits for the next call. */ maxEvents?: number; /** The contract the player has open: pilots leave it alone (decision 0.5.126). */ viewedContractId?: string | null; } export interface CatchUpResult { /** Arrivals resolved. */ resolved: number; /** Pilot decisions made (jobs, deadheads and waits). */ decisions: number; /** Every event up to `until` was processed. */ done: boolean; /** How far the replay got. */ reached: RealTime; } /** The earliest decision due by `limit`, ties to the lower pilot id. */ function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null { let best: SavedPilot | null = null; for (const pilot of save.pilots) { const t = pilot.nextDecisionAt; if (t === null || t > limit || pilot.mode !== "independent") continue; if (!best || t < best.nextDecisionAt! || (t === best.nextDecisionAt && pilot.id < best.id)) { best = pilot; } } return best; } function nextArrival(save: SaveDocument, limit: RealTime): RealTime | null { let earliest: RealTime | null = null; for (const d of save.dispatches) { if (d.status === "in-flight" && d.arrivalAt <= limit) { earliest = earliest === null ? d.arrivalAt : Math.min(earliest, d.arrivalAt); } } return earliest; } /** * One Independent pilot's decision point at `t` (CR-021 §2). Rest, the shop * and the first-look window push it later; otherwise the sim chooses, and the * choice goes through the same commands the player uses. */ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: string | null): void { const ship = save.ships.find((s) => s.id === pilot.assignedShipId); if (pilot.mode !== "independent" || !ship || save.player.tutorialActive) { pilot.nextDecisionAt = null; return; } // Flying (the player sent them): the arrival schedules the next decision. if (pilot.status === "in-flight" || ship.status === "in-flight" || !ship.locationId) { pilot.nextDecisionAt = null; return; } if (isResting(pilot.restEndsAt, t)) return scheduleDecision(pilot, pilot.restEndsAt!); if (isInShop(ship, t)) return scheduleDecision(pilot, ship.inShopUntil!); if (t < firstLookEnd(t)) { pilot.nextDecisionAt = firstLookEnd(t); return; } const random = replayRandom(pilot.id, t); const board = locationBoard(save, ship.locationId, random, t).contracts; const action = chooseAction( { ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } }, pilot: pilotState(pilot), orders: pilot.orders, board, now: t, credits: save.company.credits, spendingFloor: save.company.npcSpendingFloor, discoveredIds: discoveredIds(save), emptyPeriods: pilot.emptyPeriods, viewedContractId: viewed, }, content, ); if (action.kind === "wait") { if (ship.locationId !== pilot.orders.homeLocationId) pilot.emptyPeriods++; pilot.nextDecisionAt = nextBoardLook(t); return; } // The same commands, and so the same checks, as the player's (CR-021 §3). The // sim chose with the same rules, so a refusal is a bug; the pilot then waits. try { if (action.buyFu > 0) refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t); if (action.kind === "take") { dispatchContract( save, { contractId: action.contractId, shipId: ship.id, pilotId: pilot.id }, random, t, { independent: true }, ); } else { dispatchReposition( save, { shipId: ship.id, pilotId: pilot.id, destinationId: action.destinationId }, random, t, { independent: true }, ); } } catch { pilot.nextDecisionAt = nextBoardLook(t); return; } pilot.emptyPeriods = 0; pilot.nextDecisionAt = null; } /** * Replays time up to `until` as an event queue (decision 0.5.129): arrivals * and Independent pilots' decision points in time order; at the same moment * arrivals first, then pilots by id. Each arrival resolves at its own time, so * the result doesn't depend on how the time is split. */ export function catchUp( save: SaveDocument, until: RealTime, options: CatchUpOptions, ): CatchUpResult { const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; const decideLimit = Math.min(until, options.decideUntil); let resolved = 0; let decisions = 0; let reached = Number.NEGATIVE_INFINITY; for (let events = 0; events < maxEvents; events++) { const arrival = nextArrival(save, until); const pilot = nextDecision(save, decideLimit); const decisionAt = pilot?.nextDecisionAt ?? null; if (arrival === null && decisionAt === null) { // Pilots who would have decided after the cap pick up from `until`. if (decideLimit < until) { for (const p of save.pilots) { if (p.nextDecisionAt !== null && p.nextDecisionAt < until) p.nextDecisionAt = until; } } return { resolved, decisions, done: true, reached: until }; } if (arrival !== null && (decisionAt === null || arrival <= decisionAt)) { const landed = save.dispatches.filter( (d) => d.status === "in-flight" && d.arrivalAt <= arrival, ); resolved += resolveArrivals(save, arrival); for (const d of landed) { const flyer = save.pilots.find((p) => p.id === d.pilotId); if (flyer?.mode === "independent") { scheduleDecision(flyer, Math.max(arrival, flyer.restEndsAt ?? 0)); } } reached = arrival; } else { decide(save, pilot!, decisionAt!, options.viewedContractId ?? null); decisions++; reached = decisionAt!; } } return { resolved, decisions, done: false, reached }; } /** The decision cap for a catch-up starting from the last saved time (decision 0.5.129). */ export function decideUntil(save: SaveDocument): RealTime { return save.lastTimeSeen + content.config.independentPilots.maxCatchUpRealDays * DAY_MS; }