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

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;
}