import { content, type Location, type ShipModel } from "@deadhead/content"; import type { Ship } from "@deadhead/schema"; import { checkRoute, effectiveStats, MS_PER_HOUR, routeDistance, travelTimeGameHours, 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, }; } 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).ok) legs.add(legKey(from.id, to.id)); } } legCache.set(model.id, legs); } return legs; } export interface ShipReach { /** Per origin, the locations in range right now. */ now: { originId: string; destinationIds: string[] }[]; /** Reachable directly from an origin, but not right now. */ otherDates: 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. On the fixed * map (decision 0.5.93) `otherDates` is always empty; it goes with its wording * in Step 15.8. */ 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 nowLegs = originIds.map((originId) => ({ originId, destinationIds: ids.filter( (to) => to !== originId && checkRoute(model, location(originId), location(to)).ok, ), })); const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds)); 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 { now: nowLegs, otherDates: ids.filter((id) => direct.has(id) && !inRangeNow.has(id) && !origins.has(id)), byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)), }; }