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

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