From 7fdc43bc7e58795f6ebad9bd4ce8459e46d0d050 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 17 Sep 2026 11:51:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.6.0=20=E2=80=94=20data=20foundation:?= =?UTF-8?q?=20Drizzle=20ORM=20+=20SQLite=20(sql.js/WASM)=20persistence,=20?= =?UTF-8?q?first-run=20seed,=20read-only=20GET=20/api/state=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + .gitignore | 5 ++ CHANGELOG.md | 11 +++ app/api/state/route.test.ts | 59 ++++++++++++++ app/api/state/route.ts | 10 +++ bun.lock | 11 +++ docker-compose.yml | 3 + e2e/api.spec.ts | 4 +- e2e/state.spec.ts | 75 +++++++++++++++++ lib/db/client.test.ts | 69 ++++++++++++++++ lib/db/client.ts | 123 ++++++++++++++++++++++++++++ lib/db/schema.ts | 46 +++++++++++ lib/game/catalog.ts | 26 ++++++ lib/game/seed.test.ts | 110 +++++++++++++++++++++++++ lib/game/seed.ts | 156 ++++++++++++++++++++++++++++++++++++ lib/game/state.ts | 91 +++++++++++++++++++++ lib/geo.test.ts | 20 +++++ lib/geo.ts | 12 +++ lib/openapi.test.ts | 53 +++++++++++- lib/openapi.ts | 111 ++++++++++++++++++++++++- next.config.ts | 7 +- package.json | 7 +- playwright.config.ts | 3 + 23 files changed, 1006 insertions(+), 8 deletions(-) create mode 100644 app/api/state/route.test.ts create mode 100644 app/api/state/route.ts create mode 100644 e2e/state.spec.ts create mode 100644 lib/db/client.test.ts create mode 100644 lib/db/client.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/game/catalog.ts create mode 100644 lib/game/seed.test.ts create mode 100644 lib/game/seed.ts create mode 100644 lib/game/state.ts create mode 100644 lib/geo.test.ts create mode 100644 lib/geo.ts diff --git a/.dockerignore b/.dockerignore index 5ff1aef..7341242 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 98f6559..2742205 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fd93f..a3845a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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": "" }`, 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" diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts new file mode 100644 index 0000000..d5e9ea1 --- /dev/null +++ b/app/api/state/route.test.ts @@ -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; + 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>; + 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>; + 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); + }); +}); diff --git a/app/api/state/route.ts b/app/api/state/route.ts new file mode 100644 index 0000000..3a6af5d --- /dev/null +++ b/app/api/state/route.ts @@ -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 }); + } +} diff --git a/bun.lock b/bun.lock index 978c0f3..6d7a2a9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.yml b/docker-compose.yml index 8c03cf0..b476d6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index b985a82..40c7761 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -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 }; 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']); }); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts new file mode 100644 index 0000000..24b7cd1 --- /dev/null +++ b/e2e/state.spec.ts @@ -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>; + contracts: Array>; +} + +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()); +}); diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts new file mode 100644 index 0000000..7ec2ec7 --- /dev/null +++ b/lib/db/client.test.ts @@ -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); + }); +}); diff --git a/lib/db/client.ts b/lib/db/client.ts new file mode 100644 index 0000000..0f8960b --- /dev/null +++ b/lib/db/client.ts @@ -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; + 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 { + 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 { + 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 { + 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 | null = null; + +export function getGameDb(): Promise { + if (!ready) ready = open(defaultDbPath()); + return ready; +} + +export async function openGameDb(file: string): Promise { + return open(file); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..e8030f2 --- /dev/null +++ b/lib/db/schema.ts @@ -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; diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts new file mode 100644 index 0000000..cd26780 --- /dev/null +++ b/lib/game/catalog.ts @@ -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 = { + 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'; diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts new file mode 100644 index 0000000..512a590 --- /dev/null +++ b/lib/game/seed.test.ts @@ -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)); + }); +}); diff --git a/lib/game/seed.ts b/lib/game/seed.ts new file mode 100644 index 0000000..aac9477 --- /dev/null +++ b/lib/game/seed.ts @@ -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 }, + }; +} diff --git a/lib/game/state.ts b/lib/game/state.ts new file mode 100644 index 0000000..039ccf8 --- /dev/null +++ b/lib/game/state.ts @@ -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 { + 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, + })), + }; +} diff --git a/lib/geo.test.ts b/lib/geo.test.ts new file mode 100644 index 0000000..16d8287 --- /dev/null +++ b/lib/geo.test.ts @@ -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); + }); +}); diff --git a/lib/geo.ts b/lib/geo.ts new file mode 100644 index 0000000..cee0f9f --- /dev/null +++ b/lib/geo.ts @@ -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))); +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 81247cc..b0758b7 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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']); + }); }); diff --git a/lib/openapi.ts b/lib/openapi.ts index 570850d..0e85f4b 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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', + ], + }, }, }, }; diff --git a/next.config.ts b/next.config.ts index b22af96..9f5d608 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json index c0e81aa..abf1a77 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/playwright.config.ts b/playwright.config.ts index 8272bc4..bf3e1f9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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: {