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

342 lines
11 KiB
TypeScript

import type { GameConfig, Location, ShipModel } from "@deadhead/content";
import type { SimContent } from "./board";
import { MS_PER_HOUR, type RealTime } from "./clock";
import type { Contract } from "./contract";
import { routeDistance } from "./distance";
import { isResting } from "./duty";
import { checkRoute } from "./eligibility";
import { fuelCost } from "./fuel";
import { checkPilotEligibility, type SkillLevels } from "./pilot";
import { deriveSeed, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
export interface ShipState {
id: string;
modelId: string;
locationId: string;
inFlight: boolean;
/** Installed upgrades (CR-009); absent means none. */
upgrades?: UpgradeLevels;
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
inShopUntil?: RealTime | null;
}
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
export function shipStats(ship: ShipState, content: SimContent): EffectiveStats {
return effectiveStats(findShipModel(ship.modelId, content), ship.upgrades ?? {}, content);
}
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;
/** Resting until this real time; null when not resting (CR-004). */
restEndsAt: RealTime | null;
}
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;
/**
* Locations the player has discovered, for the no-strand rule (decision 0.5.79).
* Absent: every location counts.
*/
discoveredIds?: ReadonlySet<string>;
}
/** Every reason `validateDispatch` can fail, in check order. */
export const DISPATCH_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"ship-not-at-origin",
"pilot-resting",
"pilot-not-idle",
"pilot-not-assigned",
"over-capacity",
"cannot-land",
"out-of-range",
"would-strand",
"too-slow",
"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. */
export function tripFuelCost(
model: ShipModel,
origin: Location,
destination: Location,
config: GameConfig,
): number {
return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config));
}
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
/** A ship's trip time for a contract: cruise, overhead and handling (game hours). */
export function contractTripGameHours(
model: ShipModel,
contract: Contract,
content: SimContent,
): number {
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const distance = routeDistance(origin, destination);
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
}
/**
* The no-strand rule (decision 0.5.79): true when the ship could not fly from
* the destination to any other discovered location. `discoveredIds` absent
* means every location counts. The map is fixed (0.5.93), so arrival time
* doesn't matter.
*/
export function wouldStrand(
model: ShipModel,
destination: Location,
discoveredIds: ReadonlySet<string> | undefined,
content: SimContent,
): boolean {
return !content.locations.some(
(next) =>
next.id !== destination.id &&
(discoveredIds?.has(next.id) ?? true) &&
checkRoute(model, destination, next, content.config).ok,
);
}
/** 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 = shipStats(ship, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
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, content.config);
if (!route.ok) return fail(route.reason);
if (wouldStrand(model, destination, proposal.discoveredIds, content)) {
return fail("would-strand");
}
// Priority (decision 0.5.56): the trip must fit the delivery window.
if (
contract.priority &&
contract.deliveryWindowGameHours !== null &&
contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours
) {
return fail("too-slow");
}
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, content.config))
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 = shipStats(ship, content);
const origin = findLocation(args.originId, content);
const destination = findLocation(args.destinationId, content);
const distance = routeDistance(origin, destination);
const mass = contract?.massTonnes ?? 0;
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * 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, content.config),
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 the player has discovered; only these can be deadheaded to (CR-002). */
discoveredIds: ReadonlySet<string>;
credits: number;
now: RealTime;
}
/** Every reason `validateReposition` can fail, in check order. */
export const REPOSITION_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"pilot-resting",
"pilot-not-idle",
"pilot-not-assigned",
"already-there",
"location-undiscovered",
"cannot-land",
"out-of-range",
"would-strand",
"insufficient-credits",
] as const;
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
/**
* Validates a deadhead (CR-002): an empty flight from wherever the ship is to
* any other discovered location. Only range and landing apply; no skills are
* needed (decision 0.5.24) and bays play no part.
*/
export function validateReposition(
proposal: RepositionProposal,
content: SimContent,
): RepositionCheck {
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
if (isInShop(ship, now)) return failWith("ship-in-shop");
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
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 (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, content);
const route = checkRoute(model, origin, destination, content.config);
if (!route.ok) return failWith(route.reason);
if (wouldStrand(model, destination, discoveredIds, content)) {
return failWith("would-strand");
}
if (credits < tripFuelCost(model, origin, destination, content.config)) {
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,
);
}