import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { getGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { PLANE_TYPES } from './catalog'; import { fuelAt, 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; contractId: string | null; // Sim-clock departure time of the current flight (v0.8.0) — null when grounded, // set by dispatch while flying so clients can compute progress/ETA from the clock. departureSimTime: number | null; lat: number; lon: number; } export interface ContractState { id: string; originIcao: string; originName: string; destIcao: string; destName: string; reward: number; cargoType: string; cargoCount: number; deadlineSimTime: number; status: 'open' | 'accepted' | 'completed' | 'expired'; planeId: string | null; createdSimTime: number; } export interface StateSnapshot { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; contracts: ContractState[]; } 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, contractRows] = await Promise.all([ db.select().from(schema.account), db.select().from(schema.simState), db.select().from(schema.planes), db.select().from(schema.contracts), ]); 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 contractById = new Map(contractRows.map((c) => [c.id, c])); const livePlane = (p: schema.PlaneRow) => { if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null; const contract = contractById.get(p.contractId); const type = PLANE_TYPES[p.model]; const from = contract ? coordsByIcao.get(contract.originIcao) : undefined; const to = contract ? coordsByIcao.get(contract.destIcao) : undefined; if (!contract || !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: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) }; }; return { account: { cash: accountRow.cash, reputation: accountRow.reputation }, simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused }, 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), contractId: p.contractId, departureSimTime: p.departureSimTime, lat: live?.lat ?? p.lat, lon: live?.lon ?? p.lon, }; }), contracts: contractRows.map((c) => ({ id: c.id, originIcao: c.originIcao, originName: nameOf(c.originIcao), destIcao: c.destIcao, destName: nameOf(c.destIcao), reward: c.reward, cargoType: c.cargoType, cargoCount: c.cargoCount, deadlineSimTime: c.deadlineSimTime, status: c.status, planeId: c.planeId, createdSimTime: c.createdSimTime, })), }; }