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): 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): 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): 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>(); /** Legs a model can fly (the map is fixed, decision 0.5.93). */ function legsInRange(model: ShipModel): Set { 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, 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, 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, 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; }