import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; 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 } 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; deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; 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 { 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); return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, 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, deadlineSimTime: p.deadlineSimTime, status: p.status, planeId: p.planeId, createdSimTime: p.createdSimTime, })), }; }