216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
import { content, type Location, type ShipModel } from "@deadhead/content";
|
|
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
|
|
import {
|
|
checkRoute,
|
|
contractTripGameHours,
|
|
permanentFailure,
|
|
effectiveStats,
|
|
isInShop,
|
|
MS_PER_HOUR,
|
|
routeDistance,
|
|
travelTimeGameHours,
|
|
tripFuel,
|
|
type EffectiveStats,
|
|
type ShipState,
|
|
} from "@deadhead/sim";
|
|
|
|
/** Client-side predictions with the same sim the server uses (rule 0.1.6). */
|
|
|
|
export function location(id: string): Location {
|
|
const found = content.location(id);
|
|
if (!found) throw new Error(`Unknown location ${id}`);
|
|
return found;
|
|
}
|
|
|
|
export function shipModel(id: string): ShipModel {
|
|
const found = content.shipModel(id);
|
|
if (!found) throw new Error(`Unknown ship model ${id}`);
|
|
return found;
|
|
}
|
|
|
|
/** Route distance in million km; fixed (decision 0.5.93). */
|
|
export function distanceAt(originId: string, destinationId: string): number {
|
|
return routeDistance(location(originId), location(destinationId));
|
|
}
|
|
|
|
/** Trip duration in real milliseconds for a ship carrying `massTonnes`. */
|
|
export function tripRealMs(
|
|
model: ShipModel,
|
|
originId: string,
|
|
destinationId: string,
|
|
massTonnes: number,
|
|
) {
|
|
const hours = travelTimeGameHours(
|
|
model,
|
|
distanceAt(originId, destinationId),
|
|
massTonnes,
|
|
content.config,
|
|
);
|
|
return (hours * MS_PER_HOUR) / content.config.timeCompression;
|
|
}
|
|
|
|
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
|
export function shipStatsOf(ship: Pick<Ship, "modelId" | "upgrades">): EffectiveStats {
|
|
return effectiveStats(shipModel(ship.modelId), ship.upgrades, content);
|
|
}
|
|
|
|
/** The sim's view of an owned ship: location, flight, upgrades and shop time (CR-009). */
|
|
export function simShip(ship: Ship): ShipState {
|
|
return {
|
|
id: ship.id,
|
|
modelId: ship.modelId,
|
|
locationId: ship.locationId ?? "",
|
|
inFlight: ship.status === "in-flight",
|
|
upgrades: ship.upgrades,
|
|
inShopUntil: ship.inShopUntil,
|
|
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
|
hullConditionPct: ship.hullConditionPct,
|
|
};
|
|
}
|
|
|
|
/** A ship's longest leg from where it is: the destination and the fuel it burns. */
|
|
export interface LongestLeg {
|
|
destinationId: string;
|
|
fuelFu: number;
|
|
}
|
|
|
|
/**
|
|
* The longest leg an idle ship can fly from where it is on a full tank, to a
|
|
* discovered place (decision 0.5.113); null in flight or with nowhere in range.
|
|
*/
|
|
export function longestLeg(ship: Ship, discoveredIds: ReadonlySet<string>): LongestLeg | null {
|
|
if (!ship.locationId) return null;
|
|
const stats = shipStatsOf(ship);
|
|
const from = location(ship.locationId);
|
|
let longest: LongestLeg | null = null;
|
|
for (const to of content.locations) {
|
|
if (to.id === from.id || !discoveredIds.has(to.id)) continue;
|
|
if (!checkRoute(stats, from, to, content.config).ok) continue;
|
|
const fuelFu = tripFuel(stats, from, to, content.config);
|
|
if (!longest || fuelFu > longest.fuelFu) longest = { destinationId: to.id, fuelFu };
|
|
}
|
|
return longest;
|
|
}
|
|
|
|
/** "Low fuel" (decision 0.5.113): the tank can't fly the ship's longest leg from here. */
|
|
export function lowFuel(ship: Ship, discoveredIds: ReadonlySet<string>): LongestLeg | null {
|
|
const leg = longestLeg(ship, discoveredIds);
|
|
return leg && ship.fuelFu < leg.fuelFu ? leg : null;
|
|
}
|
|
|
|
const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`;
|
|
const legCache = new Map<string, Set<string>>();
|
|
|
|
/** Legs a model can fly (the map is fixed, decision 0.5.93). */
|
|
function legsInRange(model: ShipModel): Set<string> {
|
|
let legs = legCache.get(model.id);
|
|
if (!legs) {
|
|
legs = new Set();
|
|
for (const from of content.locations) {
|
|
for (const to of content.locations) {
|
|
if (from.id !== to.id && checkRoute(model, from, to, content.config).ok)
|
|
legs.add(legKey(from.id, to.id));
|
|
}
|
|
}
|
|
legCache.set(model.id, legs);
|
|
}
|
|
return legs;
|
|
}
|
|
|
|
export interface ShipReach {
|
|
/** Per origin, the locations in range (decision 0.5.93: always the same). */
|
|
fromOrigins: { originId: string; destinationIds: string[] }[];
|
|
/** Reachable only through other locations. */
|
|
byHopping: string[];
|
|
}
|
|
|
|
/**
|
|
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in
|
|
* range from each origin, and places it can reach only by hopping.
|
|
*/
|
|
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
|
const ever = legsInRange(model);
|
|
const ids = content.locations.map((l) => l.id);
|
|
const origins = new Set(originIds);
|
|
const fromOrigins = originIds.map((originId) => ({
|
|
originId,
|
|
destinationIds: ids.filter(
|
|
(to) =>
|
|
to !== originId && checkRoute(model, location(originId), location(to), content.config).ok,
|
|
),
|
|
}));
|
|
const direct = new Set(ids.filter((to) => originIds.some((o) => ever.has(legKey(o, to)))));
|
|
const reached = new Set(originIds);
|
|
const queue = [...originIds];
|
|
while (queue.length > 0) {
|
|
const from = queue.shift()!;
|
|
for (const to of ids) {
|
|
if (!reached.has(to) && ever.has(legKey(from, to))) {
|
|
reached.add(to);
|
|
queue.push(to);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
fromOrigins,
|
|
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Why an idle ship can't take a job now, or null when it can: no pilot, the
|
|
* pilot resting, the ship in the shop, or an Independent pilot flying it on
|
|
* their own (decision 0.5.134). Without `now`, rest is the server's `resting`
|
|
* flag and the shop isn't checked.
|
|
*/
|
|
export function notReady(
|
|
state: Pick<PlayerState, "pilots">,
|
|
ship: Ship,
|
|
now?: number,
|
|
): { reason: "no-pilot" | "independent" | "in-shop" } | { reason: "resting"; pilot: Pilot } | null {
|
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
|
if (!pilot) return { reason: "no-pilot" };
|
|
if (pilot.mode === "independent") return { reason: "independent" };
|
|
const resting =
|
|
now === undefined ? pilot.resting : pilot.restEndsAt !== null && pilot.restEndsAt > now;
|
|
if (resting) return { reason: "resting", pilot };
|
|
if (now !== undefined && isInShop(ship, now)) return { reason: "in-shop" };
|
|
return null;
|
|
}
|
|
|
|
/** Ships docked at `locationId` (or anywhere) that a job could go out on now. */
|
|
export function readyShips(
|
|
state: Pick<PlayerState, "pilots" | "ships">,
|
|
locationId?: string,
|
|
now?: number,
|
|
): Ship[] {
|
|
return state.ships.filter(
|
|
(s) =>
|
|
s.status === "idle" &&
|
|
s.locationId !== null &&
|
|
(locationId === undefined || s.locationId === locationId) &&
|
|
notReady(state, s, now) === null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* How long a job takes in game hours (the trip's travel and handling), for the
|
|
* player's fastest ship that could take it: one at the job's origin if any,
|
|
* else anywhere in the fleet. Null with no ship that could.
|
|
*/
|
|
export function jobGameHours(
|
|
state: Pick<PlayerState, "ships" | "pilots">,
|
|
contract: Contract,
|
|
): number | null {
|
|
const able = state.ships.flatMap((ship) => {
|
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
|
if (!pilot) return [];
|
|
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
|
|
if (permanentFailure(contract, member, undefined, content) !== null) return [];
|
|
return [{ ship, hours: contractTripGameHours(shipStatsOf(ship), contract, content) }];
|
|
});
|
|
const here = able.filter((a) => a.ship.locationId === contract.originId);
|
|
const pool = here.length > 0 ? here : able;
|
|
return pool.length > 0 ? Math.min(...pool.map((a) => a.hours)) : null;
|
|
}
|