deadhead-space-freight-tycoon/packages/sim/src/completable.ts

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 };
}