import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; import { getGameDb } from '@/lib/db/client'; import { getPlayer } from '@/lib/db/localPlayer'; 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'; import { JOB_LIFETIME_MS } from './seed'; // 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 cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is // also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time. 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 []; } } // v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination // joins the pool while airborne), or queued for it (queued legs count even while their plane is in // the shop — the work will be waiting at its next stop). A fleet fully repairing with empty queues // has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's // pool spawner so both agree on exactly which airports keep open jobs. export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set { const set = new Set(); for (const p of planes) { if (p.status === 'available') set.add(p.locationIcao); else if (p.status === 'flying' && p.destIcao) set.add(p.destIcao); for (const icao of parseIcaoQueue(p.queue)) set.add(icao); } return set; } 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; // Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored. distanceKm: number; deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number; // Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live // countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime). expiresRealMs: number | null; } export interface StateSnapshot { account: { cash: number; reputation: number }; // ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state — // only the clock itself is reported here. simState: { clockMs: number }; planes: PlaneState[]; packages: PackageState[]; // Market reference data (v0.10.0) — static per build, sent with every snapshot so // the client can compute buy/sell previews without a separate endpoint. catalog: CatalogEntry[]; fuelPricePerL: number; sellFraction: number; } // ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component // renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot). // Terminal history (delivered/abandoned) is unbounded in the DB and never read by the // client, so it stays out of every pushed frame; /api/state and action responses keep the full set. export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const; export function sseFrame(snapshot: StateSnapshot): StateSnapshot { return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) }; } // v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same // shape as before ({cash, reputation}), sourced per-player. Callers without a session context // (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112). export async function getStateSnapshot(playerId: string): Promise { const inst = await getGameDb(); const { db } = inst; // 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 [player, simRows, planeRows, packageRows] = await Promise.all([ getPlayer(inst, playerId), db.select().from(schema.simState), db.select().from(schema.planes), db.select().from(schema.packages), ]); const simRow = simRows[0]; if (!simRow) throw new Error('game state missing 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])); // v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing. const legDistanceKm = (originIcao: string, destIcao: string) => { const from = coordsByIcao.get(originIcao); const to = coordsByIcao.get(destIcao); return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0; }; // v0.65.0: pool visibility — open packages appear only where a plane can take work (poolAirports); // every other status is always visible (on-board cargo, history). Filtered rows are absent from the // body entirely, so clients never see jobs they could not dispatch. const pool = poolAirports(planeRows); // 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: player.cash, reputation: player.reputation }, simState: { clockMs: simRow.clockMs }, 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.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).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, distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095) deadlineSimTime: p.deadlineSimTime, status: p.status, planeId: p.planeId, createdSimTime: p.createdSimTime, expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null, })), }; }