import type { SimContent } from "./board"; import type { RealTime } from "./clock"; import type { Contract } from "./contract"; import { contractTripGameHours, findLocation, findShipModel, tripFuel, wouldStrand, type DispatchFailure, } from "./dispatch"; import { checkRoute } from "./eligibility"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { effectiveStats, type UpgradeLevels } from "./upgrades"; /** * One ship-and-pilot pair's lasting capabilities (decision 0.5.115): the ship's * model and upgrades, its pilot's skills, and the fuel on board (which counts * only where the job's origin sells no fuel). */ export interface FleetMember { modelId: string; skills: SkillLevels; /** Installed upgrades: effective stats count (CR-009). */ upgrades?: UpgradeLevels; /** Fuel on board; absent means a full tank. */ fuelFu?: number; } /** The permanent checks, in order (decision 0.5.116): the dispatch reasons that can't be waited out. */ export const PERMANENT_FAILURES = [ "over-capacity", "cannot-land", "out-of-range", "would-strand", "too-slow", "cargo-skill-too-low", "long-haul-too-low", ] as const satisfies readonly DispatchFailure[]; export type PermanentFailure = (typeof PERMANENT_FAILURES)[number]; /** * The first permanent check a pair fails for a contract, or null when it could * fly it once rested, out of the shop and refuelled (decision 0.5.116). Same * rules and order as `validateDispatch`, but the tank counts as full wherever * the origin sells fuel. */ export function permanentFailure( contract: Contract, member: FleetMember, discoveredIds: ReadonlySet | undefined, content: SimContent, ): PermanentFailure | null { const model = effectiveStats( findShipModel(member.modelId, content), member.upgrades ?? {}, content, ); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); if (contract.massTonnes > model.cargoCapacityTonnes) return "over-capacity"; const route = checkRoute(model, origin, destination, content.config); if (!route.ok) return route.reason; // Where the origin sells fuel the tank can be filled first; elsewhere only what's aboard. const available = origin.sellsFuel ? model.tankCapacityFu : Math.min(member.fuelFu ?? model.tankCapacityFu, model.tankCapacityFu); const leg = tripFuel(model, origin, destination, content.config); if (leg > available) return "out-of-range"; if (wouldStrand(model, destination, available - leg, discoveredIds, content)) { return "would-strand"; } if ( contract.priority && contract.deliveryWindowGameHours !== null && contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours ) { return "too-slow"; } const pilot = checkPilotEligibility(member.skills, contract); return pilot.ok ? null : pilot.reason; } export interface Completability { /** At least one pair passes every permanent check (decision 0.5.114). */ completable: boolean; /** Per pair, in order: the first failing permanent check, or null. */ failures: (PermanentFailure | null)[]; } /** Whether any pair in `fleet` could complete `contract` (decisions 0.5.114, 0.5.116). */ export function completability( contract: Contract, fleet: readonly FleetMember[], discoveredIds: ReadonlySet | undefined, content: SimContent, ): Completability { const failures = fleet.map((m) => permanentFailure(contract, m, discoveredIds, content)); return { completable: failures.some((f) => f === null), failures }; } /** Shorthand: true when some pair could complete the contract. */ export function isCompletable( contract: Contract, fleet: readonly FleetMember[], discoveredIds: ReadonlySet | undefined, content: SimContent, ): boolean { return fleet.some((m) => permanentFailure(contract, m, discoveredIds, content) === null); } /** An owned ship as the qualifying-fleet rule sees it. */ export interface FleetShip { id: string; modelId: string; upgrades?: UpgradeLevels; fuelFu?: number; /** Where it is; null in flight. */ locationId: string | null; /** In flight: where to, and when it lands. */ destinationId?: string | null; arrivalAt?: RealTime | null; } /** A pilot as the qualifying-fleet rule sees it. */ export interface FleetPilot { id: string; skills: SkillLevels; assignedShipId: string | null; /** Where an unassigned pilot is; null otherwise. */ locationId: string | null; } export interface QualifyingFleetInput { locationId: string; /** In a stable order: the snapshot (and so the board) follows it. */ ships: readonly FleetShip[]; pilots: readonly FleetPilot[]; /** Locations with a player bay: unassigned pilots there can be assigned. */ bayLocationIds: ReadonlySet; /** The end of the board period: ships landing before then count. */ periodEndsAt: RealTime; } export interface QualifyingFleet { members: FleetMember[]; /** No pair qualified at the location, so this is the whole fleet. */ wholeFleet: boolean; } const member = (ship: FleetShip, pilot: FleetPilot): FleetMember => ({ modelId: ship.modelId, skills: pilot.skills, ...(ship.upgrades ? { upgrades: ship.upgrades } : {}), ...(ship.fuelFu !== undefined ? { fuelFu: ship.fuelFu } : {}), }); /** * The pairs a location's board is made for (decision 0.5.115): ships there, or * landing there before the period ends, each with its assigned pilot, or with * any unassigned pilot there when the player has a bay there. With no such * pair, every ship with its pilot, or with any unassigned pilot, anywhere. */ export function qualifyingFleet(input: QualifyingFleetInput): QualifyingFleet { const { locationId, ships, pilots } = input; const unassigned = pilots.filter((p) => p.assignedShipId === null); const pairsFor = (ship: FleetShip, spare: readonly FleetPilot[]) => [ ...pilots.filter((p) => p.assignedShipId === ship.id).map((p) => member(ship, p)), ...spare.map((p) => member(ship, p)), ]; const here = ships.filter( (s) => s.locationId === locationId || (s.locationId === null && s.destinationId === locationId && s.arrivalAt != null && s.arrivalAt < input.periodEndsAt), ); const spareHere = input.bayLocationIds.has(locationId) ? unassigned.filter((p) => p.locationId === locationId) : []; const local = here.flatMap((s) => pairsFor(s, spareHere)); if (local.length > 0) return { members: local, wholeFleet: false }; return { members: ships.flatMap((s) => pairsFor(s, unassigned)), wholeFleet: true }; }