Files
Flight-Ops/lib/game/state.ts
T
2026-09-21 09:30:36 +00:00

163 lines
6.3 KiB
TypeScript

import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
export interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
locationName: string;
// Current flight destination (v0.12.0) — set by fly, null when grounded. The
// origin of the current leg is always locationIcao while flying.
destIcao: string | null;
destName: string | null;
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
// set by fly while flying so clients can compute progress/ETA from the clock.
departureSimTime: number | null;
lat: number;
lon: number;
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
queue: string[];
}
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
export function parseIcaoQueue(raw: string | null): string[] {
if (!raw) return [];
try {
const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
export interface PackageState {
id: string;
originIcao: string;
originName: string;
destIcao: string;
destName: string;
reward: number;
cargoType: string;
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
createdSimTime: number;
}
export interface StateSnapshot {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint.
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
export async function getStateSnapshot(): Promise<StateSnapshot> {
const { db } = await getGameDb();
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
db.select().from(schema.account),
db.select().from(schema.simState),
db.select().from(schema.planes),
db.select().from(schema.packages),
]);
const accountRow = accountRows[0];
const simRow = simRows[0];
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row');
const fc = await loadAirportsGeoJson();
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock.
const livePlane = (p: schema.PlaneRow) => {
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) return null;
const type = PLANE_TYPES[p.model];
const from = coordsByIcao.get(p.locationIcao);
const to = coordsByIcao.get(p.destIcao);
if (!type || !from || !to) return null;
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
};
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION,
planes: planeRows.map((p) => {
const live = livePlane(p);
return {
id: p.id,
model: p.model,
status: p.status,
fuel: live?.fuel ?? p.fuel,
maxFuel: p.maxFuel,
locationIcao: p.locationIcao,
locationName: nameOf(p.locationIcao),
destIcao: p.destIcao,
destName: p.destIcao ? nameOf(p.destIcao) : null,
departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon,
queue: parseIcaoQueue(p.queue),
};
}),
packages: packageRows.map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
})),
};
}