266 lines
8.4 KiB
TypeScript
266 lines
8.4 KiB
TypeScript
import type { Location, ShipModel } from "@deadhead/content";
|
|
import type { SimContent } from "./board";
|
|
import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock";
|
|
import type { Contract } from "./contract";
|
|
import { routeDistance } from "./distance";
|
|
import { checkRoute } from "./eligibility";
|
|
import { fuelCost } from "./fuel";
|
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
|
import { deriveSeed, type Seed } from "./rng";
|
|
import { travelTimeGameHours } from "./travel";
|
|
|
|
export interface ShipState {
|
|
id: string;
|
|
modelId: string;
|
|
locationId: string;
|
|
inFlight: boolean;
|
|
}
|
|
|
|
export interface PilotState {
|
|
id: string;
|
|
/** The player's own pilot: exempt from pilot pay (decision 0.5.12). */
|
|
isAvatar: boolean;
|
|
assignedShipId: string | null;
|
|
inFlight: boolean;
|
|
skills: SkillLevels;
|
|
}
|
|
|
|
export interface DispatchProposal {
|
|
/** For a tutorial run, the company loaner (decision 0.5.26: it is not in the ships list). */
|
|
ship: ShipState;
|
|
pilot: PilotState;
|
|
contract: Contract;
|
|
/** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */
|
|
tutorial: boolean;
|
|
credits: number;
|
|
now: RealTime;
|
|
}
|
|
|
|
/** Every reason `validateDispatch` can fail, in check order. */
|
|
export const DISPATCH_FAILURES = [
|
|
"ship-not-idle",
|
|
"ship-not-at-origin",
|
|
"pilot-not-idle",
|
|
"pilot-not-assigned",
|
|
"over-capacity",
|
|
"cannot-land",
|
|
"out-of-range",
|
|
"cargo-skill-too-low",
|
|
"long-haul-too-low",
|
|
"contract-expired",
|
|
"insufficient-credits",
|
|
] as const;
|
|
export type DispatchFailure = (typeof DISPATCH_FAILURES)[number];
|
|
|
|
export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure };
|
|
|
|
export function findShipModel(id: string, content: SimContent): ShipModel {
|
|
const model = content.shipModels.find((s) => s.id === id);
|
|
if (!model) throw new Error(`Unknown ship model ${id}`);
|
|
return model;
|
|
}
|
|
|
|
export function findLocation(id: string, content: SimContent): Location {
|
|
const location = content.locations.find((l) => l.id === id);
|
|
if (!location) throw new Error(`Unknown location ${id}`);
|
|
return location;
|
|
}
|
|
|
|
/** Fuel charged for a trip, in whole credits, using the distance at departure. */
|
|
export function tripFuelCost(
|
|
model: ShipModel,
|
|
origin: Location,
|
|
destination: Location,
|
|
departure: GameTime,
|
|
): number {
|
|
return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure)));
|
|
}
|
|
|
|
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
|
|
|
|
/** Validates a proposed contract dispatch, returning the first failing reason. */
|
|
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
|
|
const { ship, pilot, contract, tutorial, credits, now } = proposal;
|
|
const model = findShipModel(ship.modelId, content);
|
|
const origin = findLocation(contract.originId, content);
|
|
const destination = findLocation(contract.destinationId, content);
|
|
const departure = realToGame(now, content.config);
|
|
|
|
if (ship.inFlight) return fail("ship-not-idle");
|
|
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
|
|
if (pilot.inFlight) return fail("pilot-not-idle");
|
|
// Decision 0.5.23: during the tutorial the avatar flies only the loaner.
|
|
if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) {
|
|
return fail("pilot-not-assigned");
|
|
}
|
|
if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity");
|
|
|
|
const route = checkRoute(model, origin, destination, departure);
|
|
if (!route.ok) return fail(route.reason);
|
|
|
|
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
|
|
if (!pilotCheck.ok) return fail(pilotCheck.reason);
|
|
|
|
if (now >= contract.expiresAt) return fail("contract-expired");
|
|
|
|
if (!tutorial) {
|
|
if (credits < tripFuelCost(model, origin, destination, departure))
|
|
return fail("insufficient-credits");
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export interface DispatchRecord {
|
|
id: string;
|
|
shipId: string;
|
|
pilotId: string;
|
|
/** Null for a reposition. */
|
|
contract: Contract | null;
|
|
originId: string;
|
|
destinationId: string;
|
|
tutorial: boolean;
|
|
departureAt: RealTime;
|
|
arrivalAt: RealTime;
|
|
/** Route distance at departure, in million km. */
|
|
distanceMillionKm: number;
|
|
/** Credits charged to the player; 0 for tutorial runs (the company pays). */
|
|
fuelCost: number;
|
|
expectedPayout: number;
|
|
/** Seed for anything rolled about this trip. */
|
|
seed: Seed;
|
|
}
|
|
|
|
export interface RecordInput {
|
|
id: string;
|
|
ship: ShipState;
|
|
pilot: PilotState;
|
|
contract: Contract;
|
|
tutorial: boolean;
|
|
now: RealTime;
|
|
worldSeed: Seed;
|
|
}
|
|
|
|
function buildRecord(
|
|
args: {
|
|
id: string;
|
|
ship: ShipState;
|
|
pilot: PilotState;
|
|
contract: Contract | null;
|
|
originId: string;
|
|
destinationId: string;
|
|
tutorial: boolean;
|
|
now: RealTime;
|
|
worldSeed: Seed;
|
|
},
|
|
content: SimContent,
|
|
): DispatchRecord {
|
|
const { id, ship, pilot, contract, tutorial, now, worldSeed } = args;
|
|
const { config } = content;
|
|
const model = findShipModel(ship.modelId, content);
|
|
const origin = findLocation(args.originId, content);
|
|
const destination = findLocation(args.destinationId, content);
|
|
const departure = realToGame(now, config);
|
|
const distance = routeDistance(origin, destination, departure);
|
|
const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR;
|
|
|
|
return {
|
|
id,
|
|
shipId: ship.id,
|
|
pilotId: pilot.id,
|
|
contract,
|
|
originId: origin.id,
|
|
destinationId: destination.id,
|
|
tutorial,
|
|
departureAt: now,
|
|
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
|
distanceMillionKm: distance,
|
|
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure),
|
|
expectedPayout: contract?.payout ?? 0,
|
|
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
|
};
|
|
}
|
|
|
|
/** Turns an already-validated dispatch into its record. Same inputs give the same record. */
|
|
export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord {
|
|
const { contract } = input;
|
|
return buildRecord(
|
|
{ ...input, originId: contract.originId, destinationId: contract.destinationId },
|
|
content,
|
|
);
|
|
}
|
|
|
|
export interface RepositionProposal {
|
|
ship: ShipState;
|
|
pilot: PilotState;
|
|
destinationId: string;
|
|
/** Locations where the player has a bay (bays exist only at discovered locations). */
|
|
bayLocationIds: ReadonlySet<string>;
|
|
credits: number;
|
|
now: RealTime;
|
|
}
|
|
|
|
/** Every reason `validateReposition` can fail, in check order. */
|
|
export const REPOSITION_FAILURES = [
|
|
"ship-not-idle",
|
|
"pilot-not-idle",
|
|
"pilot-not-assigned",
|
|
"already-there",
|
|
"no-bay-at-destination",
|
|
"cannot-land",
|
|
"out-of-range",
|
|
"insufficient-credits",
|
|
] as const;
|
|
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
|
|
|
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
|
|
|
|
/**
|
|
* Validates an empty flight to one of the player's bays. Only range and landing
|
|
* apply; no skills are needed (decision 0.5.24).
|
|
*/
|
|
export function validateReposition(
|
|
proposal: RepositionProposal,
|
|
content: SimContent,
|
|
): RepositionCheck {
|
|
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
|
|
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
|
|
|
if (ship.inFlight) return failWith("ship-not-idle");
|
|
if (pilot.inFlight) return failWith("pilot-not-idle");
|
|
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
|
if (destinationId === ship.locationId) return failWith("already-there");
|
|
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
|
|
|
|
const model = findShipModel(ship.modelId, content);
|
|
const origin = findLocation(ship.locationId, content);
|
|
const destination = findLocation(destinationId, content);
|
|
const departure = realToGame(now, content.config);
|
|
const route = checkRoute(model, origin, destination, departure);
|
|
if (!route.ok) return failWith(route.reason);
|
|
|
|
if (credits < tripFuelCost(model, origin, destination, departure)) {
|
|
return failWith("insufficient-credits");
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export interface RepositionRecordInput {
|
|
id: string;
|
|
ship: ShipState;
|
|
pilot: PilotState;
|
|
destinationId: string;
|
|
now: RealTime;
|
|
worldSeed: Seed;
|
|
}
|
|
|
|
/** Record for a validated reposition: no contract, fuel only, zero payout. */
|
|
export function createRepositionRecord(
|
|
input: RepositionRecordInput,
|
|
content: SimContent,
|
|
): DispatchRecord {
|
|
return buildRecord(
|
|
{ ...input, contract: null, originId: input.ship.locationId, tutorial: false },
|
|
content,
|
|
);
|
|
}
|