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 { eventDueAt, nextEventDue, settleDueEvent } from "./events"; import { finishShop, refuelShip } from "./fleet"; import { logReadyGoals } from "./logbook"; import { scheduleDecision } from "./independent"; import { resolveArrivals } from "./resolve"; import { discoveredIds, pilotState, shipState, sleepsAt } 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; } /** The earliest open shop end by `limit` (decisions 0.5.135, 0.5.141). */ function nextShopEnd(save: SaveDocument, limit: RealTime): SaveDocument["ships"][number] | null { let best: SaveDocument["ships"][number] | null = null; for (const ship of save.ships) { const t = ship.inShopUntil; if (!ship.shopOpen || t === null || t > limit) continue; if (!best || t < best.inShopUntil! || (t === best.inShopUntil && ship.id < best.id)) best = ship; } 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, "pilot"); } 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): shop ends * (decision 0.5.135), arrivals and Independent pilots' decision points in time * order; at the same moment shop ends first, then arrivals, 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 { // A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3). save.company.lastManualDecisionAt ??= until; // Goals already ready before this catch-up count from when the save was last seen, so // the time is the same however the catch-up is split (decision 0.5.141). logReadyGoals(save, save.lastTimeSeen); const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; // Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137). const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1); let resolved = 0; let decisions = 0; let reached = Number.NEGATIVE_INFINITY; for (let events = 0; events < maxEvents; events++) { // A trip's event comes first at any moment: it can move the arrival (decision 0.5.143). const due = nextEventDue(save, until); if (due) { const at = eventDueAt(due)!; const shopFirst = nextShopEnd(save, until); const arrivalFirst = nextArrival(save, until); const decisionFirst = nextDecision(save, decideLimit)?.nextDecisionAt ?? null; const earliest = Math.min( shopFirst?.inShopUntil ?? Infinity, arrivalFirst ?? Infinity, decisionFirst ?? Infinity, ); if (at <= earliest) { settleDueEvent(save, due); reached = at; continue; } } const shop = nextShopEnd(save, until); const shopAt = shop?.inShopUntil ?? null; const arrival = nextArrival(save, until); const pilot = nextDecision(save, decideLimit); const decisionAt = pilot?.nextDecisionAt ?? null; if ( shopAt !== null && (arrival === null || shopAt <= arrival) && (decisionAt === null || shopAt <= decisionAt) ) { finishShop(save, shop!); logReadyGoals(save, shopAt); reached = shopAt; continue; } if (arrival === null && decisionAt === null) { // Pilots who would have decided after the cap, or asleep, pick up from `until`: // the next manual decision wakes them from there (decision 0.5.138). 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); logReadyGoals(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; }