import type { SimContent } from "./board"; import { boardPeriodIndex, boardPeriodMs } from "./board"; import { MS_PER_MINUTE, type RealTime } from "./clock"; import type { Contract } from "./contract"; import { contractTripGameHours, findLocation, shipStats, tripFuel, validateDispatch, validateReposition, type PilotState, type ShipState, } from "./dispatch"; import { fuelPriceAt, refuel, type Tank } from "./fuel"; import { pilotShare } from "./pay"; /** What the player tells an Independent pilot (decision 0.5.124). */ export interface PilotOrders { /** Destinations allowed; null means every discovered location. */ destinationIds: readonly string[] | null; /** Cargo classes allowed (the pilot's skills limit them anyway). */ cargoClassIds: readonly string[]; takePriority: boolean; /** Credits: the least profit (payout − fuel − pilot share) a job must make. */ minProfit: number; /** Where the pilot heads after empty boards away (decision 0.5.128). */ homeLocationId: string; } /** The orders a pilot starts with (CR-021 §2): anything goes, home where hired. */ export function defaultOrders(homeLocationId: string, content: SimContent): PilotOrders { return { destinationIds: null, cargoClassIds: content.cargoTypes.map((c) => c.id), takePriority: true, minProfit: 0, homeLocationId, }; } export interface IndependentInput { /** The pilot's ship, idle at the board's location, with its tank. */ ship: ShipState & { tank: Tank }; pilot: PilotState; orders: PilotOrders; /** The board where the ship is: completable for the fleet, minus jobs already taken. */ board: readonly Contract[]; now: RealTime; credits: number; /** Credits pilots never spend the company below (decision 0.5.127). */ spendingFloor: number; discoveredIds: ReadonlySet; /** Board periods in a row this pilot has found nothing to take (decision 0.5.128). */ emptyPeriods: number; /** The contract the player has open, which pilots leave alone (decision 0.5.126). */ viewedContractId?: string | null; } /** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */ function fuelToBuy(burnFu: number, tank: Tank): number { const short = burnFu - tank.fuelFu; return short > 0 ? Math.ceil(short * 1000) / 1000 : 0; } /** * The fuel a pilot may buy for a leg (decision 0.5.127): none if the tank holds * enough; otherwise exactly what the leg needs, if the location sells it and * credits stay at or above the spending floor. Null when they can't. */ export function legRefuel( input: Pick, burnFu: number, content: SimContent, ): { buyFu: number; cost: number; tank: Tank } | null { const { ship } = input; const buyFu = fuelToBuy(burnFu, ship.tank); if (buyFu === 0) return { buyFu: 0, cost: 0, tank: ship.tank }; const location = findLocation(ship.locationId, content); if (!location.sellsFuel) return null; const capacity = shipStats(ship, content).tankCapacityFu; if (ship.tank.fuelFu + buyFu > capacity + 1e-9) return null; const bought = refuel(ship.tank, capacity, buyFu, fuelPriceAt(location, content.config)); if (input.credits - bought.cost < input.spendingFloor) return null; return { buyFu, cost: bought.cost, tank: bought.tank }; } /** A job an Independent pilot could take, with its fuel and profit (decision 0.5.125). */ export interface JobOption { contract: Contract; /** Fuel bought first (0 if the tank holds enough). */ buyFu: number; /** Fuel aboard at its average price, plus fuel bought at the local price. */ fuelCost: number; pilotShare: number; profit: number; tripGameHours: number; profitPerHour: number; } /** Whether the first-look window of the current board period is over (decision 0.5.126). */ export function pastFirstLook(now: RealTime, content: SimContent): boolean { const periodStart = boardPeriodIndex(now, content.config) * boardPeriodMs(content.config); return now - periodStart >= content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE; } /** * The jobs on the board this pilot may take now (decisions 0.5.124–0.5.127): * past the first-look window, not first contacts, not the one the player has * open, within the orders, flyable by this pair once the leg's fuel is bought * (every dispatch check), and making at least the minimum profit. */ export function independentOptions(input: IndependentInput, content: SimContent): JobOption[] { const { ship, pilot, orders, now } = input; if (!pastFirstLook(now, content)) return []; const model = shipStats(ship, content); const origin = findLocation(ship.locationId, content); const options: JobOption[] = []; for (const contract of input.board) { if (contract.firstContact || contract.id === input.viewedContractId) continue; if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue; if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue; if (contract.priority && !orders.takePriority) continue; const destination = findLocation(contract.destinationId, content); const burnFu = tripFuel(model, origin, destination, content.config); const fuel = legRefuel(input, burnFu, content); if (!fuel) continue; const check = validateDispatch( { ship: { ...ship, tank: fuel.tank }, pilot, contract, tutorial: false, now, discoveredIds: input.discoveredIds, }, content, ); if (!check.ok) continue; const fromTank = Math.min(burnFu, ship.tank.fuelFu); const fuelCost = Math.round(fromTank * ship.tank.avgPrice + fuel.cost); const share = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuelCost, content.config); const profit = contract.payout - fuelCost - share; if (profit < orders.minProfit) continue; const tripGameHours = contractTripGameHours(model, contract, content); options.push({ contract, buyFu: fuel.buyFu, fuelCost, pilotShare: share, profit, tripGameHours, profitPerHour: profit / tripGameHours, }); } return options; } /** Best first: highest profit per hour, then the shorter trip, then the lower contract id. */ export function compareOptions(a: JobOption, b: JobOption): number { if (a.profitPerHour !== b.profitPerHour) return b.profitPerHour - a.profitPerHour; if (a.tripGameHours !== b.tripGameHours) return a.tripGameHours - b.tripGameHours; return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0; } /** What an Independent pilot does when they can act (CR-021 §2). */ export type PilotAction = | { kind: "take"; contractId: string; buyFu: number } | { kind: "deadhead-home"; destinationId: string; buyFu: number } | { kind: "wait" }; /** * An Independent pilot's decision (decisions 0.5.125, 0.5.128): the best job * on the board; with none, after enough empty periods away from home, a * deadhead home if the ship can make it under the fuel rules; else wait. */ export function chooseAction(input: IndependentInput, content: SimContent): PilotAction { const [best] = independentOptions(input, content).sort(compareOptions); if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu }; const { ship, orders } = input; const home = orders.homeLocationId; if ( ship.locationId !== home && input.emptyPeriods >= content.config.independentPilots.emptyPeriodsBeforeHome ) { const model = shipStats(ship, content); const burnFu = tripFuel( model, findLocation(ship.locationId, content), findLocation(home, content), content.config, ); const fuel = legRefuel(input, burnFu, content); if ( fuel && validateReposition( { ship: { ...ship, tank: fuel.tank }, pilot: input.pilot, destinationId: home, discoveredIds: input.discoveredIds, now: input.now, }, content, ).ok ) { return { kind: "deadhead-home", destinationId: home, buyFu: fuel.buyFu }; } } return { kind: "wait" }; }