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
|
node_modules
|
||||||
.next
|
.next
|
||||||
data/*.db
|
data/*.db
|
||||||
|
# game state DB (v0.6.0): host-side, volume-mounted at runtime — never baked in
|
||||||
|
/db
|
||||||
test-results
|
test-results
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ test-results/
|
|||||||
data/*.db
|
data/*.db
|
||||||
data/*.sqlite*
|
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`,
|
# generated map-asset pack (~100 MB) — produced by `npm run assets:download`,
|
||||||
# served via the docker-compose volume mount, never tracked (v0.4.1)
|
# served via the docker-compose volume mount, never tracked (v0.4.1)
|
||||||
public/styles/
|
public/styles/
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## 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)
|
- 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"
|
- 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",
|
"name": "flight-ops",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"maplibre-gl": "^6.10.0",
|
"maplibre-gl": "^6.10.0",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
|
"sql.js": "^1.14.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.20.3",
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/sql.js": "^1.4.11",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.1",
|
||||||
},
|
},
|
||||||
@@ -158,6 +161,8 @@
|
|||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
"@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/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
"@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/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-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=="],
|
"@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=="],
|
"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=="],
|
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
|
||||||
|
|
||||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
"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=="],
|
"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=="],
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
"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.
|
# populates ./public; absent/empty → app falls back to remote OFM styles.
|
||||||
volumes:
|
volumes:
|
||||||
- "./public:/app/public"
|
- "./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:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
labels:
|
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);
|
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');
|
const res = await page.request.get('/api/openapi');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
expect(res.headers()['content-type']).toContain('application/json');
|
expect(res.headers()['content-type']).toContain('application/json');
|
||||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
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.title).toBe('Flight Ops API');
|
||||||
expect(spec.info.version).toBe(pkg.version);
|
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';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the two public endpoints', () => {
|
it('documents exactly the three public endpoints', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/airports', '/api/version']);
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
|
'/api/airports',
|
||||||
|
'/api/state',
|
||||||
|
'/api/version',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps info.version in sync with package.json', () => {
|
it('keeps info.version in sync with package.json', () => {
|
||||||
@@ -46,4 +50,49 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(props)).toEqual(['version']);
|
expect(Object.keys(props)).toEqual(['version']);
|
||||||
expect(props.version.type).toBe('string');
|
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: {
|
info: {
|
||||||
title: 'Flight Ops API',
|
title: 'Flight Ops API',
|
||||||
description:
|
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.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
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': {
|
'/api/version': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'App version',
|
summary: 'App version',
|
||||||
@@ -104,6 +122,97 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['name', 'icao', 'lat', 'lon'],
|
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';
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -11,15 +11,18 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"maplibre-gl": "^6.10.0",
|
"maplibre-gl": "^6.10.0",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0"
|
"react-dom": "^19.3.0",
|
||||||
|
"sql.js": "^1.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.20.3",
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/sql.js": "^1.4.11",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
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,
|
timeout: 30_000,
|
||||||
retries: 1,
|
retries: 1,
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
Reference in New Issue
Block a user