Files
Flight-Ops/lib/game/state.ts
2026-09-21 12:06:45 +00:00

173 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { conditionBurnMultiplier, 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;
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
status: 'available' | 'flying' | 'repairing';
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[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null;
}
// 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.
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
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)) * conditionBurnMultiplier(p.health),
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),
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
repairUntilSimTime: p.repairUntilSimTime,
};
}),
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,
})),
};
}