188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
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<string> | 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<string> | 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<string> | 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<string>;
|
|
/** 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 };
|
|
}
|