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:
@@ -1,6 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
data/*.db
|
||||
# game state DB (v0.6.0): host-side, volume-mounted at runtime — never baked in
|
||||
/db
|
||||
test-results
|
||||
*.tsbuildinfo
|
||||
.git
|
||||
|
||||
@@ -17,6 +17,11 @@ test-results/
|
||||
data/*.db
|
||||
data/*.sqlite*
|
||||
|
||||
# game state SQLite (v0.6.0): lives on the host, volume-mounted into the
|
||||
# container as ./db → /app/db so it survives rebuilds — never tracked.
|
||||
# Anchored to repo root: an unanchored "db/" would also ignore lib/db/ (source).
|
||||
/db/
|
||||
|
||||
# generated map-asset pack (~100 MB) — produced by `npm run assets:download`,
|
||||
# served via the docker-compose volume mount, never tracked (v0.4.1)
|
||||
public/styles/
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 — 2026-09-17 11:49
|
||||
- Data foundation (PLAN.md "Simulation engine" increment 1): Drizzle ORM + SQLite via **sql.js (WASM)** — chosen over better-sqlite3 because it needs zero native compilation, so the existing oven/bun-alpine install stages and node:22-alpine runtime work unchanged; deps `drizzle-orm@0.45`, `sql.js@1.14` (+ dev-only `@types/sql.js`)
|
||||
- Schema (`lib/db/schema.ts`, DDL mirrored in `lib/db/client.ts`): `planes` (id, model, status available|flying, fuel/maxFuel, locationIcao, contractId?, lat/lon), `contracts` (id, origin/dest ICAO, reward, cargoType/cargoCount, deadlineSimTime, status open|accepted|completed, planeId?, createdSimTime), single-row `account` (cash, reputation) and `sim_state` (clockMs, timeScale 1–60, paused)
|
||||
- First-run seed (`lib/game/seed.ts`) — pure `buildSeed({airports, rng, nowMs})`, fully deterministic with an injected RNG (mulberry32 PRNG exported for tests): starter plane **ATR 72-600** (in-code catalog constant in `lib/game/catalog.ts`: maxFuel 6300 L, burnPerKm 1.75 L/km, speedKmh 510 — structured so more types can be added) grounded full-tank at Frankfurt (**EDDF**; EDDM is Munich), 8–12 open contracts between random distinct CSV airports with reward `round(500 + 2.5 · haversineKm)` (documented simple formula; real revenue/cost modeling is a later increment), cargo flavor (Passengers ≤68 / pallets ≤50), deadline = now + 6–72h game time; account starts at cash 20 000 / reputation 10; simState clockMs = real now, timeScale 1, paused false
|
||||
- Persistence: DB file `db/flight.db` on the host (gitignored) volume-mounted in docker-compose as `./db:/app/db` — a dedicated dir, NOT over `/app/data` (which would mask airports_eu.csv); sql.js keeps the DB in memory, so `persist()` exports the bytes and writes them atomically (tmp file + rename) after every mutation; seeding happens lazily on first access when the account table is empty; `FLIGHT_DB_PATH` env override lets tests point at temp files
|
||||
- New read-only `GET /api/state` (`app/api/state/route.ts`): full snapshot `{account, simState, planes[], contracts[]}` for client polling — plane/contract shapes include airport names (locationName / originName / destName) resolved from the CSV; no tick loop yet, state is static after seed
|
||||
- OpenAPI: `/api/state` documented with component schemas StateSnapshot/AccountState/SimState/PlaneState/ContractState (`lib/openapi.ts`)
|
||||
- `next.config.ts`: `serverExternalPackages: ['sql.js']` — the WASM blob resolves relative to sql.js's dist dir, so it must not be bundled into a .next chunk; known drizzle-orm 0.45.x quirk worked around in code (commented): the sql-js driver drops the result mapper for relational `db.query.*` queries and returns raw snake_case rows, so all reads use plain `select()`
|
||||
- Tests: unit — new `lib/geo.test.ts` (haversine), `lib/game/seed.test.ts` (determinism with fixed RNG, 8–12 count, distinct endpoints, reward formula vs haversine, deadline window, hub + fallback, account/simState values), `lib/db/client.test.ts` (first-run seed + on-disk file, no re-seed on reopen, persist() round-trip) and `app/api/state/route.test.ts` (200 + exact top-level shape against a temp DB); openapi tests updated for the third endpoint; e2e — new `e2e/state.spec.ts`: snapshot shape from the live origin, plus **restart-persistence** test that records cash/clockMs/ids, runs `docker compose restart flight`, waits for readiness and asserts everything unchanged (PLAN.md "sim clock persisted across sessions"); playwright.config gains `workers: 1` so the mid-run container restart can't race the map tests
|
||||
- Verified: unit suite 47/47; `tsc --noEmit` clean; e2e 11/11 green against the rebuilt compose instance (incl. both new state tests); live curl `/api/state` → Frankfurt starter plane + 11 contracts, `/api/airports` still serves 242 features after the mount change; persistence across a full `docker compose up -d --build` verified manually (clockMs/cash/contract count identical before/after)
|
||||
|
||||
## v0.5.0 — 2026-09-17 10:15
|
||||
- New `GET /api/version` (`app/api/version/route.ts`): returns `{ "version": "<semver>" }`, version read from package.json via a server-side import (same single-source-of-truth pattern as the header tag)
|
||||
- OpenAPI 3.1 spec in code: `lib/openapi.ts` exports an object documenting exactly the two public endpoints — `GET /api/airports` (GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) and `GET /api/version`; `info.version` is imported from package.json so it stays in sync automatically; `servers: [{ url: "" }]` means "current origin"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { GET } from './route';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Point the app's DB singleton at a throwaway file so this test never
|
||||
// touches ./db/flight.db. getGameDb() reads the env var on first call, and
|
||||
// vitest gives each test file its own module registry (fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/state', () => {
|
||||
it('returns 200 with the full snapshot shape', async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
|
||||
|
||||
const account = body.account as { cash: number; reputation: number };
|
||||
expect(typeof account.cash).toBe('number');
|
||||
expect(typeof account.reputation).toBe('number');
|
||||
|
||||
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean };
|
||||
expect(typeof sim.clockMs).toBe('number');
|
||||
expect(typeof sim.timeScale).toBe('number');
|
||||
expect(sim.paused).toBe(false);
|
||||
|
||||
const planes = body.planes as Array<Record<string, unknown>>;
|
||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
expect(typeof planes[0].locationName).toBe('string');
|
||||
|
||||
const contracts = body.contracts as Array<Record<string, unknown>>;
|
||||
expect(contracts.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
||||
expect(contracts[0], `contract.${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
||||
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
||||
expect((await b).account.cash).toBe((await a).account.cash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('failed to load game state', err);
|
||||
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,18 @@
|
||||
"": {
|
||||
"name": "flight-ops",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"sql.js": "^1.14.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1",
|
||||
},
|
||||
@@ -158,6 +161,8 @@
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
@@ -168,6 +173,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
@@ -228,6 +235,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||
|
||||
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
@@ -318,6 +327,8 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
@@ -5,6 +5,9 @@ services:
|
||||
# populates ./public; absent/empty → app falls back to remote OFM styles.
|
||||
volumes:
|
||||
- "./public:/app/public"
|
||||
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
||||
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
|
||||
- "./db:/app/db"
|
||||
networks:
|
||||
- dev_net
|
||||
labels:
|
||||
|
||||
+2
-2
@@ -13,12 +13,12 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with both endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all three endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
||||
expect(spec.info.title).toBe('Flight Ops API');
|
||||
expect(spec.info.version).toBe(pkg.version);
|
||||
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/version']);
|
||||
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/state', '/api/version']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||
// this host (the test runner), browsers stay remote.
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: Array<Record<string, unknown>>;
|
||||
contracts: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
const body = (await res.json()) as StateBody;
|
||||
|
||||
expect(typeof body.account.cash).toBe('number');
|
||||
expect(typeof body.account.reputation).toBe('number');
|
||||
|
||||
expect(typeof body.simState.clockMs).toBe('number');
|
||||
expect(typeof body.simState.timeScale).toBe('number');
|
||||
expect(body.simState.paused).toBe(false);
|
||||
|
||||
expect(Array.isArray(body.planes)).toBe(true);
|
||||
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
|
||||
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
expect(Array.isArray(body.contracts)).toBe(true);
|
||||
expect(body.contracts.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
||||
expect(body.contracts[0], `contract.${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const resBefore = await page.request.get('/api/state');
|
||||
expect(resBefore.status()).toBe(200);
|
||||
const before = (await resBefore.json()) as StateBody;
|
||||
|
||||
execSync('docker compose restart flight', { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
// Wait for the app to come back, capturing the post-restart snapshot.
|
||||
let after: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) after = (await res.json()) as StateBody;
|
||||
return !!after;
|
||||
} catch {
|
||||
return false; // connection refused / 502 while the container starts
|
||||
}
|
||||
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
||||
.toBe(true);
|
||||
|
||||
expect(after).toBeDefined();
|
||||
const a = after!;
|
||||
|
||||
// The sim clock and account must be byte-identical — the DB file on the
|
||||
// volume is loaded, not re-seeded (PLAN.md: "sim clock persisted across sessions").
|
||||
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
||||
expect(a.account.cash).toBe(before.account.cash);
|
||||
expect(a.account.reputation).toBe(before.account.reputation);
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
expect(a.contracts.map((c) => c.id).sort()).toEqual(before.contracts.map((c) => c.id).sort());
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
const nextConfig: NextConfig = {
|
||||
// sql.js loads its SQLite WASM blob relative to its own dist/ dir at runtime;
|
||||
// bundling it into a .next chunk would break that resolution. Keeping it
|
||||
// external means require('sql.js') hits node_modules (kept in the image).
|
||||
serverExternalPackages: ['sql.js'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -11,15 +11,18 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
"react-dom": "^19.3.0",
|
||||
"sql.js": "^1.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) {
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// Serial execution: e2e/state.spec.ts restarts the compose container mid-run,
|
||||
// which would break any map test running concurrently in another worker.
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
retries: 1,
|
||||
use: {
|
||||
|
||||
Reference in New Issue
Block a user