feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot

This commit is contained in:
2026-09-17 11:51:42 +00:00
parent 4d9a1e9fa9
commit 7fdc43bc7e
23 changed files with 1006 additions and 8 deletions
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { openGameDb, persist } from './client';
import * as schema from './schema';
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-db-'));
});
afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
describe('openGameDb', () => {
it('seeds a fresh DB file on first open and persists it to disk', async () => {
const file = path.join(dir, 'fresh.db');
const inst = await openGameDb(file);
const planes = await inst.db.select().from(schema.planes);
expect(planes).toHaveLength(1);
expect(planes[0].id).toBe('plane-starter');
expect(planes[0].status).toBe('available');
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
expect(planes[0].fuel).toBe(planes[0].maxFuel);
const contracts = await inst.db.select().from(schema.contracts);
expect(contracts.length).toBeGreaterThanOrEqual(8);
expect(contracts.every((c) => c.status === 'open')).toBe(true);
const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0);
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim?.paused).toBe(false);
expect(sim?.timeScale).toBe(1);
// The seed was durably written: the file exists and is non-empty.
const st = await fs.stat(file);
expect(st.size).toBeGreaterThan(0);
});
it('does not re-seed an existing DB — state survives reopen', async () => {
const file = path.join(dir, 'reopen.db');
const first = await openGameDb(file);
const contracts1 = (await first.db.select().from(schema.contracts)).map((c) => c.id).sort();
const account1 = (await first.db.select().from(schema.account))[0];
// Simulate a process restart: brand-new instance over the same file.
const second = await openGameDb(file);
const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort();
expect(contracts2).toEqual(contracts1);
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
});
it('persist() durably saves writes made after open', async () => {
const file = path.join(dir, 'write.db');
const inst = await openGameDb(file);
await inst.db.update(schema.account).set({ cash: 12345 });
await persist(inst);
// A fresh instance over the same file sees the update.
const reloaded = await openGameDb(file);
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { randomInt } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildSeed, mulberry32 } from '@/lib/game/seed';
import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
// alpine Docker image without a toolchain. Trade-off: the DB lives in memory and
// every write must be exported to disk; persist() does that atomically (tmp file
// + rename) and is called after each mutation.
export interface GameDb {
db: SQLJsDatabase<typeof schema>;
raw: RawSqlJsDatabase;
file: string;
}
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync.
const DDL = `
CREATE TABLE IF NOT EXISTS planes (
id TEXT PRIMARY KEY NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
contract_id TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS contracts (
id TEXT PRIMARY KEY NOT NULL,
origin_icao TEXT NOT NULL,
dest_icao TEXT NOT NULL,
reward REAL NOT NULL,
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
created_sim_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS account (
cash REAL NOT NULL,
reputation REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS sim_state (
clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL,
paused INTEGER NOT NULL
);
`;
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
// of the real ./db/flight.db.
export function defaultDbPath(): string {
return process.env.FLIGHT_DB_PATH ?? path.join(process.cwd(), 'db', 'flight.db');
}
async function open(file: string): Promise<GameDb> {
const SQL = await initSqlJs();
let bytes: Buffer | undefined;
try {
bytes = await fs.readFile(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL);
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
await seedEmpty(instance);
}
return instance;
}
async function seedEmpty(instance: GameDb): Promise<void> {
const fc = await loadAirportsGeoJson();
const airports = fc.features.map((f) => ({
icao: f.properties.icao,
name: f.properties.name,
lat: f.properties.lat,
lon: f.properties.lon,
}));
// Random per-install seed; tests call buildSeed directly with a fixed RNG.
const rng = mulberry32(randomInt(0, 2 ** 31));
const data = buildSeed({ airports, rng, nowMs: Date.now() });
await instance.db.insert(schema.planes).values(data.plane);
await instance.db.insert(schema.contracts).values(data.contracts);
await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState);
await persist(instance);
}
// Durably save the in-memory DB: export bytes, write to a temp file, rename over
// the target so a crash mid-write never leaves a truncated DB.
export async function persist(instance: GameDb): Promise<void> {
const bytes = instance.raw.export();
await fs.mkdir(path.dirname(instance.file), { recursive: true });
const tmp = `${instance.file}.tmp`;
await fs.writeFile(tmp, Buffer.from(bytes));
await fs.rename(tmp, instance.file);
}
// Singleton for the app process; openGameDb() is the uncached variant (tests).
let ready: Promise<GameDb> | null = null;
export function getGameDb(): Promise<GameDb> {
if (!ready) ready = open(defaultDbPath());
return ready;
}
export async function openGameDb(file: string): Promise<GameDb> {
return open(file);
}
+46
View File
@@ -0,0 +1,46 @@
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
// Drizzle schema for the game state. Column names/types here are the source of
// truth; the matching CREATE TABLE statements live in lib/db/client.ts (DDL) and
// must stay in sync with this file.
export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(),
model: text('model').notNull(),
status: text('status', { enum: ['available', 'flying'] }).notNull(),
fuel: real('fuel').notNull(),
maxFuel: real('max_fuel').notNull(),
locationIcao: text('location_icao').notNull(),
contractId: text('contract_id'),
lat: real('lat').notNull(),
lon: real('lon').notNull(),
});
export const contracts = sqliteTable('contracts', {
id: text('id').primaryKey().notNull(),
originIcao: text('origin_icao').notNull(),
destIcao: text('dest_icao').notNull(),
reward: real('reward').notNull(),
cargoType: text('cargo_type').notNull(),
cargoCount: integer('cargo_count').notNull(),
deadlineSimTime: integer('deadline_sim_time').notNull(),
status: text('status', { enum: ['open', 'accepted', 'completed'] }).notNull(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
});
export const account = sqliteTable('account', {
cash: real('cash').notNull(),
reputation: real('reputation').notNull(),
});
export const simState = sqliteTable('sim_state', {
clockMs: integer('clock_ms').notNull(),
timeScale: real('time_scale').notNull(),
paused: integer('paused', { mode: 'boolean' }).notNull(),
});
export type PlaneRow = typeof planes.$inferSelect;
export type ContractRow = typeof contracts.$inferSelect;
export type AccountRow = typeof account.$inferSelect;
export type SimStateRow = typeof simState.$inferSelect;
+26
View File
@@ -0,0 +1,26 @@
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
export interface PlaneType {
id: string;
name: string;
// Fuel capacity, liters.
maxFuel: number;
// Fuel burn per km of flight, liters/km (cruise).
burnPerKm: number;
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
speedKmh: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
atr72: {
id: 'atr72',
name: 'ATR 72-600',
maxFuel: 6300,
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
speedKmh: 510,
},
};
// The plane the player starts with.
export const STARTER_MODEL = 'atr72';
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import {
BASE_REWARD,
buildSeed,
CONTRACT_COUNT_MAX,
CONTRACT_COUNT_MIN,
HUB_ICAO,
mulberry32,
REWARD_PER_KM,
rewardForDistanceKm,
STARTING_CASH,
STARTING_REPUTATION,
type SeedAirport,
} from './seed';
const NOW = 1_750_000_000_000;
// Small fixed fixture — includes the hub (EDDF Frankfurt) so the happy path is covered.
const AIRPORTS: SeedAirport[] = [
{ icao: 'EDDF', name: 'Frankfurt Main', lat: 50.0333, lon: 8.5706 },
{ icao: 'EHAM', name: 'Amsterdam Schiphol', lat: 52.31, lon: 4.76 },
{ icao: 'EGLL', name: 'London Heathrow', lat: 51.47, lon: -0.454 },
{ icao: 'LEBC', name: 'Madrid Barajas', lat: 40.47, lon: -3.56 },
{ icao: 'LIRF', name: 'Rome Fiumicino', lat: 41.8, lon: 12.25 },
];
const seedWith = (rngSeed: number, airports: SeedAirport[] = AIRPORTS) =>
buildSeed({ airports, rng: mulberry32(rngSeed), nowMs: NOW });
describe('buildSeed', () => {
it('grounds the starter plane full of fuel at the hub airport', () => {
const seed = seedWith(42);
const type = PLANE_TYPES[STARTER_MODEL];
expect(seed.plane).toEqual({
id: 'plane-starter',
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel,
maxFuel: type.maxFuel,
locationIcao: HUB_ICAO,
contractId: null,
lat: 50.0333,
lon: 8.5706,
});
});
it('falls back to the first airport when the hub ICAO is missing', () => {
const seed = buildSeed({ airports: AIRPORTS.slice(1), rng: mulberry32(42), nowMs: NOW });
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open contracts between distinct fixture airports', () => {
const seed = seedWith(42);
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
for (const c of seed.contracts) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
}
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
});
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
}
});
it('sets deadlines inside the 6–72h game-time window after now', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
}
});
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
expect(seedWith(42)).toEqual(seedWith(42));
});
it('produces different contracts for a different RNG seed', () => {
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
});
it('rejects an airport list too small to form a route', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
it('rewardForDistanceKm applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
});
});
+156
View File
@@ -0,0 +1,156 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never
// read internally. Production calls this with a crypto-random seed; tests use
// mulberry32(fixed).
export interface SeedAirport {
icao: string;
name: string;
lat: number;
lon: number;
}
export type Rng = () => number;
// Small deterministic PRNG (mulberry32) — good enough for game seeding, and it
// makes tests reproducible. Returns floats in [0, 1).
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface SeedPlane {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
contractId: null;
lat: number;
lon: number;
}
export interface SeedContract {
id: string;
originIcao: string;
destIcao: string;
reward: number;
cargoType: string;
cargoCount: number;
deadlineSimTime: number;
status: 'open';
planeId: null;
createdSimTime: number;
}
export interface SeedData {
plane: SeedPlane;
contracts: SeedContract[];
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false };
}
// --- Tunables (documented so future revenue/cost work can build on them) ---
export const STARTING_CASH = 20_000;
export const STARTING_REPUTATION = 10;
// The starter plane grounds at a large central-European hub. If the ICAO is
// ever missing from the dataset we fall back to the first airport rather than
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const CONTRACT_COUNT_MIN = 8;
export const CONTRACT_COUNT_MAX = 12; // inclusive
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
// Contract deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
}
function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
}
export function buildSeed(input: {
airports: SeedAirport[];
rng: Rng;
nowMs: number;
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const type = PLANE_TYPES[STARTER_MODEL];
const hub = pickHub(airports);
const plane: SeedPlane = {
id: 'plane-starter',
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel, // starts full
maxFuel: type.maxFuel,
locationIcao: hub.icao,
contractId: null,
lat: hub.lat,
lon: hub.lon,
};
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const contracts: SeedContract[] = [];
for (let i = 0; i < count; i++) {
// Stable index-based ids for the seed set; runtime-generated entities must
// use random UUIDs to avoid colliding with these.
const oi = Math.floor(rng() * airports.length);
let di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
const origin = airports[oi];
const dest = airports[di];
const km = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const cargoType = CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)];
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
const deadlineMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
contracts.push({
id: `contract-${i + 1}`,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardForDistanceKm(km),
cargoType,
cargoCount,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
createdSimTime: nowMs,
});
}
return {
plane,
contracts,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
simState: { clockMs: nowMs, timeScale: 1, paused: false },
};
}
+91
View File
@@ -0,0 +1,91 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
// 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;
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';
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<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, 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;
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
planes: planeRows.map((p) => ({
id: p.id,
model: p.model,
status: p.status,
fuel: p.fuel,
maxFuel: p.maxFuel,
locationIcao: p.locationIcao,
locationName: nameOf(p.locationIcao),
contractId: p.contractId,
lat: p.lat,
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,
})),
};
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
expect(haversineKm(50.03, 8.57, 50.03, 8.57)).toBeCloseTo(0, 10);
});
it('computes the London → Paris great-circle distance (~343 km)', () => {
const km = haversineKm(51.47, -0.454, 49.0097, 2.5479); // LHR → CDG
expect(km).toBeGreaterThan(330);
expect(km).toBeLessThan(360);
});
it('is symmetric', () => {
const a = haversineKm(50.03, 8.57, 40.47, -3.56); // FRA → MAD
const b = haversineKm(40.47, -3.56, 50.03, 8.57);
expect(a).toBeCloseTo(b, 10);
});
});
+12
View File
@@ -0,0 +1,12 @@
const EARTH_RADIUS_KM = 6371;
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
+51 -2
View File
@@ -3,8 +3,12 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the two public endpoints', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/airports', '/api/version']);
it('documents exactly the three public endpoints', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/state',
'/api/version',
]);
});
it('keeps info.version in sync with package.json', () => {
@@ -46,4 +50,49 @@ describe('OpenAPI spec', () => {
expect(Object.keys(props)).toEqual(['version']);
expect(props.version.type).toBe('string');
});
it('describes /api/state as a StateSnapshot with account, simState, planes and contracts', () => {
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
const snap = openApiSpec.components.schemas.StateSnapshot.properties;
expect(Object.keys(snap).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
'contractId',
'fuel',
'id',
'lat',
'locationIcao',
'locationName',
'lon',
'maxFuel',
'model',
'status',
]);
const contract = openApiSpec.components.schemas.ContractState.properties;
expect(Object.keys(contract).sort()).toEqual([
'cargoCount',
'cargoType',
'createdSimTime',
'deadlineSimTime',
'destIcao',
'destName',
'id',
'originIcao',
'originName',
'planeId',
'reward',
'status',
]);
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
});
});
+110 -1
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'Flight Ops API',
description:
'JSON API for Flight Ops — EU airport data for the map, plus app metadata.',
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, and app metadata.',
// Sourced from package.json so it stays in sync with every version bump.
version: pkg.version,
},
@@ -33,6 +33,24 @@ export const openApiSpec = {
},
},
},
'/api/state': {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and contracts. Read-only.',
operationId: 'getState',
responses: {
200: {
description: 'Current game state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/StateSnapshot' },
},
},
},
},
},
},
'/api/version': {
get: {
summary: 'App version',
@@ -104,6 +122,97 @@ export const openApiSpec = {
},
required: ['name', 'icao', 'lat', 'lon'],
},
StateSnapshot: {
type: 'object',
properties: {
account: { $ref: '#/components/schemas/AccountState' },
simState: { $ref: '#/components/schemas/SimState' },
planes: {
type: 'array',
items: { $ref: '#/components/schemas/PlaneState' },
},
contracts: {
type: 'array',
items: { $ref: '#/components/schemas/ContractState' },
},
},
required: ['account', 'simState', 'planes', 'contracts'],
},
AccountState: {
type: 'object',
properties: {
cash: { type: 'number' },
reputation: { type: 'number' },
},
required: ['cash', 'reputation'],
},
SimState: {
type: 'object',
properties: {
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
timeScale: { type: 'number', minimum: 1, maximum: 60 },
paused: { type: 'boolean' },
},
required: ['clockMs', 'timeScale', 'paused'],
},
PlaneState: {
type: 'object',
properties: {
id: { type: 'string' },
model: { type: 'string' },
status: { enum: ['available', 'flying'] },
fuel: { type: 'number' },
maxFuel: { type: 'number' },
locationIcao: { type: 'string' },
locationName: { type: 'string' },
contractId: { type: ['string', 'null'] },
lat: { type: 'number' },
lon: { type: 'number' },
},
required: [
'id',
'model',
'status',
'fuel',
'maxFuel',
'locationIcao',
'locationName',
'contractId',
'lat',
'lon',
],
},
ContractState: {
type: 'object',
properties: {
id: { type: 'string' },
originIcao: { type: 'string' },
originName: { type: 'string' },
destIcao: { type: 'string' },
destName: { type: 'string' },
reward: { type: 'number' },
cargoType: { type: 'string' },
cargoCount: { type: 'integer' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
status: { enum: ['open', 'accepted', 'completed'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
},
required: [
'id',
'originIcao',
'originName',
'destIcao',
'destName',
'reward',
'cargoType',
'cargoCount',
'deadlineSimTime',
'status',
'planeId',
'createdSimTime',
],
},
},
},
};