deadhead-space-freight-tycoon/apps/web/src/game.ts

127 lines
4.0 KiB
TypeScript

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