import type { SimContent } from "./board"; import type { RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; import { findLocation } from "./dispatch"; import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty"; import { affordableRefuel, fuelPriceAt, type Tank } from "./fuel"; import { splitLoanRepayment } from "./loan"; import { pilotShare } from "./pay"; import type { SkillLevels } from "./pilot"; import { deliveryXp } from "./xp"; import { payoutAfterEvent, type AppliedEvent } from "./events"; /** Auto top-up (default) fills the tank on landing; Manual never does (decision 0.5.97). */ export type RefuelPolicy = "auto" | "manual"; /** Fuel an Auto top-up bought on landing (decision 0.5.97). */ export interface TopUp { locationId: string; boughtFu: number; /** Credits per fu. */ price: number; /** Whole credits, paid on top of the delivery's `credits`. */ cost: number; /** The tank afterwards. */ tank: Tank; } export interface Resolution { /** * Credits to add: payout minus pilot pay, minus the loan repayment. Fuel * came out of the tank at departure; any top-up is `topUp.cost`. */ credits: number; /** Auto top-up on landing, or null (Manual, nothing bought, or no tank). */ topUp: TopUp | null; /** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */ pilotPay: number; /** Credits that went to the lender out of this payout (decision 0.5.32). */ loanRepayment: number; pilotXp: number; /** Where the ship (or tutorial loaner) is now. */ shipLocationId: string; /** True when this delivery discovers the destination. */ discoversDestination: boolean; /** The pilot's duty hours after this trip: 0 if it ended in mandatory rest (CR-004). */ pilotDutyHours: number; /** Mandatory rest from the arrival time, or null (decision 0.5.45). */ pilotRestEndsAt: RealTime | null; } export type ResolveResult = { status: "in-flight" } | { status: "resolved"; resolution: Resolution }; export interface ResolveInput { record: DispatchRecord; /** The avatar is exempt from pilot pay (decision 0.5.12). */ pilotIsAvatar: boolean; discoveredIds: ReadonlySet; /** The company's loan balance before this resolution. */ loanBalance: number; /** The pilot's duty hours before this trip, and skills (Long Haul sets the limit). */ pilotDutyHours: number; pilotSkills: SkillLevels; now: RealTime; /** * The ship's tank on landing, its policy, and the company's credits before this * resolution (Auto top-up buys what they cover). Absent: no top-up (the * tutorial loaner uses no fuel). */ fuel?: { tank: Tank; capacityFu: number; refuelPolicy: RefuelPolicy; credits: number }; /** The trip's event once applied (CR-026): it changes the pay and XP. */ event?: AppliedEvent | null; } /** Auto top-up on landing (decision 0.5.97): as much as the credits after this trip cover. */ function topUpOnLanding(input: ResolveInput, net: number, content: SimContent): TopUp | null { const { fuel, record } = input; if (!fuel || record.tutorial || fuel.refuelPolicy !== "auto") return null; const location = findLocation(record.destinationId, content); if (!location.sellsFuel) return null; const price = fuelPriceAt(location, content.config); const bought = affordableRefuel(fuel.tank, fuel.capacityFu, price, fuel.credits + net); if (bought.boughtFu <= 0) return null; return { locationId: location.id, boughtFu: bought.boughtFu, price, cost: bought.cost, tank: bought.tank, }; } /** * The pilot's duty after a trip (decisions 0.5.42–0.5.45): the trip's game * hours are added (tutorial runs add none); at or over the limit, rest starts * at the arrival time and duty resets, however late the trip is resolved. */ function dutyOutcome(input: ResolveInput, content: SimContent) { const { record, pilotDutyHours, pilotSkills } = input; const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config); const duty = dutyAfterTrip(pilotDutyHours, hours, record.tutorial); if (!record.tutorial && needsRest(duty, dutyLimit(pilotSkills, content.config))) { return { pilotDutyHours: 0, pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config) }; } return { pilotDutyHours: duty, pilotRestEndsAt: null }; } /** * XP for a delivery (Step 6.8); tutorial runs earn it × the tutorial XP * multiplier, so the avatar has a skill point by the deal (decision 0.5.107). */ export function deliveryXpFor(record: DispatchRecord, tier: number, content: SimContent): number { const xp = deliveryXp(record.distanceMillionKm, tier, content.config); return record.tutorial ? Math.round(xp * content.config.tutorialXpMultiplier) : xp; } /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input; if (now < record.arrivalAt) return { status: "in-flight" }; const { contract } = record; const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) }; if (contract === null) { // Deadheads earn nothing and only fly to discovered locations. return { status: "resolved", resolution: { ...base, credits: 0, topUp: topUpOnLanding(input, 0, content), pilotPay: 0, loanRepayment: 0, pilotXp: 0, discoversDestination: false, }, }; } // An event's losses, fees and bonuses change what the shipper pays (CR-026). const payout = payoutAfterEvent(contract.payout, input.event); // Profit-share pay (decision 0.5.12, CR-010), taken before the loan repayment (0.5.69). const pilotPay = record.tutorial || pilotIsAvatar ? 0 : pilotShare(payout, record.fuelCost, content.config); // Tutorial contracts already store exactly the tutorial share as their payout. const { repayment, net } = splitLoanRepayment( payout - pilotPay, loanBalance, content.config.loanRepaymentShare, ); return { status: "resolved", resolution: { ...base, credits: net, topUp: topUpOnLanding(input, net, content), pilotPay, loanRepayment: repayment, pilotXp: Math.round( deliveryXpFor(record, contract.tier, content) * (1 + (input.event?.xpBonusPct ?? 0) / 100), ), discoversDestination: !discoveredIds.has(record.destinationId), }, }; }