import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { boardPeriodIndex, effectiveStats, pilotLevel, resolveDispatch, skillPointsAvailable, type RealTime, type Resolution, } from "@deadhead/sim"; import { logEntry, pruneLogbook } from "./logbook"; import { discoveredIds } from "./state"; /** * Resolves every in-flight dispatch that has arrived by `now`, in arrival * order (Steps 6.1, 7.2): credits (net of pilot pay and loan repayment), Auto * top-up (CR-015), XP, * duty and rest, ship and pilot locations, tutorial progress and discovery. * A resolved dispatch is never resolved again, so running this twice in a * row changes nothing the second time. Returns how many were resolved. */ export function resolveArrivals(save: SaveDocument, now: RealTime): number { const due = save.dispatches .filter((d) => d.status === "in-flight" && d.arrivalAt <= now) .sort((a, b) => a.arrivalAt - b.arrivalAt); if (due.length === 0) return 0; const discovered = discoveredIds(save); let resolved = 0; for (const dispatch of due) { const pilot = save.pilots.find((p) => p.id === dispatch.pilotId); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined; const model = ship ? content.shipModel(ship.modelId) : undefined; const result = resolveDispatch( { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds: discovered, // Each arrival repays against the balance left by the previous one. loanBalance: save.company.loanBalance, pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, event: dispatch.eventApplied, fuel: ship && model ? { tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, capacityFu: effectiveStats(model, ship.upgrades, content).tankCapacityFu, refuelPolicy: ship.refuelPolicy, credits: save.company.credits, } : undefined, }, content, ); if (result.status !== "resolved") continue; const r = result.resolution; resolved++; Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now }); save.company.credits += r.credits; save.company.loanBalance -= r.loanRepayment; // Auto top-up on landing (decision 0.5.97). if (r.topUp && ship) { save.company.credits -= r.topUp.cost; ship.fuelFu = r.topUp.tank.fuelFu; ship.fuelAvgPrice = r.topUp.tank.avgPrice; } const levelBefore = pilotLevel(pilot.xp, content.config); pilot.xp += r.pilotXp; pilot.status = "idle"; // Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45). pilot.dutyHours = r.pilotDutyHours; if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt; // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId; if (ship) { ship.status = "idle"; ship.locationId = r.shipLocationId; ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt); } if (dispatch.tutorial) { save.player.loanerLocationId = r.shipLocationId; save.player.tutorialRunsCompleted++; } else if (dispatch.record.contract) { save.stats.contractDeliveries++; if (dispatch.record.contract.priority) save.stats.priorityDeliveries++; } const discovers = r.discoversDestination && !discovered.has(dispatch.record.destinationId); if (discovers) { save.discoveredLocations.push({ locationId: dispatch.record.destinationId, discoveredAt: now, }); discovered.add(dispatch.record.destinationId); } logArrival(save, dispatch, r, now, discovers, levelBefore, pilot); } return resolved; } /** One arrival in the logbook (decision 0.5.141): the flight and what came with it. */ function logArrival( save: SaveDocument, dispatch: SaveDocument["dispatches"][number], r: Resolution, now: RealTime, discovers: boolean, levelBefore: number, pilot: SaveDocument["pilots"][number], ): void { const { record } = dispatch; const contract = record.contract; const who = { pilotId: pilot.id, shipId: dispatch.shipId }; logEntry(save, `${dispatch.id}:flight`, { at: now, ...who, kind: "flight", dispatchId: dispatch.id, deadhead: contract === null, tutorial: dispatch.tutorial, independent: dispatch.independent, originId: record.originId, destinationId: record.destinationId, goodId: contract?.goodId ?? null, massTonnes: contract?.massTonnes ?? null, shipper: contract?.shipper ?? null, payout: Math.max(0, r.credits + r.pilotPay + r.loanRepayment), pilotPay: r.pilotPay, fuelCost: record.fuelCost, loanRepayment: r.loanRepayment, xp: r.pilotXp, scriptedId: contract?.scriptedId ?? null, }); if (r.topUp && r.topUp.boughtFu > 0) { logEntry(save, `${dispatch.id}:top-up`, { at: now, ...who, kind: "refuel", locationId: r.topUp.locationId, boughtFu: r.topUp.boughtFu, cost: r.topUp.cost, source: "top-up", }); } if (r.pilotRestEndsAt !== null) { logEntry(save, `${dispatch.id}:rest`, { at: now, ...who, kind: "rest", until: r.pilotRestEndsAt, }); } if (discovers) { logEntry(save, `${dispatch.id}:discovery`, { at: now, ...who, kind: "discovery", locationId: record.destinationId, }); } const level = pilotLevel(pilot.xp, content.config); if (level > levelBefore) { logEntry(save, `${dispatch.id}:level`, { at: now, ...who, kind: "level-up", level, skillPoints: skillPointsAvailable(level, pilot.skillPointsSpent, content.config), }); } } /** * Keeps the save small (CR-014 §2, Step 7.3): drops boards and accepted * contracts older than the board retention, and resolved dispatches the player * has seen that are older than the dispatch retention. In-flight dispatches, * unseen summaries and the goal counters are never touched. */ export function pruneSave(save: SaveDocument, now: RealTime): void { const cutoffPeriod = boardPeriodIndex(now, content.config) - content.config.boardRetentionPeriods; save.contractBoards = save.contractBoards.filter((b) => b.periodIndex >= cutoffPeriod); save.acceptedContracts = save.acceptedContracts.filter((a) => a.periodIndex >= cutoffPeriod); const cutoff = now - content.config.savedDispatchRetentionRealHours * 3_600_000; pruneLogbook(save, now); save.dispatches = save.dispatches.filter( (d) => d.status === "in-flight" || d.acknowledgedAt === null || (d.resolvedAt ?? d.arrivalAt) >= cutoff, ); }