diff --git a/CHANGELOG.md b/CHANGELOG.md index a3845a6..a818977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.7.0 — 2026-09-17 12:27 +- **Server-authoritative sim engine** (PLAN.md "Simulation engine" increment 2) in new `lib/sim/` — no frontend/UI changes; dispatch of planes lands next increment, but all the clock/market/finalization machinery it builds on is live and tested now +- **Tick-on-read game clock** (`lib/sim/engine.ts`, NO background timer): every read/action endpoint calls `tickNow()` first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advances `clockMs` by `(real ms since last tick) × timeScale`. New `sim_state.last_real_tick_ms` column stores the real-time anchor (seed sets it to the seed moment). **Clamp: `MAX_TICK_ELAPSED_MS = 2000`** — a single tick counts at most 2 s of real absence, chosen above a normal ~1 s poll interval (active play advances ~real time × scale) but small enough that one stale read after an absence moves game time by ≤ 2 s × scale (≤ 2 min at max scale 60), so the sim only meaningfully progresses while actively polled. While paused the clock is frozen but `lastRealTickMs` keeps tracking real time, so unpausing resumes with no catch-up jump. Ticks are serialized through a module-level promise lock (concurrent Next.js requests can't double-advance) and each tick persists via the existing sql.js atomic export +- **Analytic movement** (`lib/sim/movement.ts`, pure functions of the sim clock — no incremental stepping): `travelDurationMs(distanceKm, speedKmh)`, `arrivalTimeFor(departureSimTime, distanceKm, speedKmh)`, `progressAt(...)` (clamped 0–1), `positionAt(fromLat, fromLon, toLat, toLon, t)` (great-circle slerp — exact endpoints, stays on the arc), `fuelAt(startFuel, burnPerKm, distanceKm, progress)` (`start − burn·distanceSoFar`, clamped at 0; range exhaustion not modeled yet). New nullable `planes.departure_sim_time` column carries the flight context (route comes from the bound contract, speed/burn from the catalog by model); while flying, `fuel` is frozen at its departure value and GET /api/state reports live lat/lon/fuel computed from the clock +- **Arrival finalization** (in-tick): a flying plane whose `arrivalTime ≤ clock` gets its contract marked completed, the exact reward added to `account.cash`, reputation += `1 + 0.0005 × km` (rounded to 2 dp — ~1.15 rep for a 300 km hop, ~2.5 for a 3000 km leg; slow growth from the starting 10), and the plane grounded at the destination with remaining fuel + `departureSimTime = null`. Idempotent by construction: finalization leaves the 'flying' state, so an award can never be paid twice (unit-tested) +- **Contract expiry** (in-tick): any `open` contract with `deadlineSimTime ≤ clockMs` → status `'expired'` (new enum value in schema/DDL/snapshot/OpenAPI); accepted/in-flight contracts never expire — they complete. Expired contracts leave the available set the client sees +- **Market spawn** (in-tick, after expiry): if open contracts < `MARKET_TARGET_OPEN_CONTRACTS = 10`, new ones are generated to top it up using the SAME builder as the first-run seed — `generateContract({id, airports, rng, nowMs, deadlineOffsetMs?})` extracted from `buildSeed` (same RNG call order, reward formula, cargo flavor, 6–72h game-time deadlines; spawn uses crypto-random UUID ids and the sim clock as "now"). Injectable RNG for deterministic tests +- **Time controls endpoint**: new `POST /api/sim` — body `{ timeScale?: number, paused?: boolean }`; unknown fields / non-number timeScale / non-boolean paused → 400; timeScale clamped to [1, 60]; ticks the clock first so the returned simState already reflects current game time. Documented in `lib/openapi.ts` (new SimControlsRequest + Error component schemas; ContractState status enum gains 'expired'; /api/state description notes tick-on-read) +- **DB migration for existing installs**: `openGameDb()` now runs a guarded `ALTER TABLE ADD COLUMN` step (PRAGMA table_info check) adding `planes.departure_sim_time` and `sim_state.last_real_tick_ms` to pre-v0.7.0 files, then fixes up the ALTER-default `last_real_tick_ms = 0` to "now" so old DBs resume cleanly instead of taking one clamped catch-up jump +- **e2e test hook** (test-only affordance): new `POST /api/test/expire-contract`, enabled ONLY when `FLIGHT_TEST_HOOKS=1` (set in docker-compose.yml for the e2e instance; 404 everywhere else) — with `{contractId}` it moves that open contract's deadline ~10 game-seconds out, without a body it creates a fresh short-deadline open contract; lets the suite observe expiry at scale 60 instead of waiting hours +- Tests: unit — new `lib/sim/movement.test.ts` (duration/arrival math, progress boundaries incl. just-before-arrival, slerp endpoints/on-arc/equator-exact/same-point, fuel burn + zero clamp) and `lib/sim/engine.test.ts` (fake-timer clock advance × timeScale, pause freeze + clean resume, 1-hour-gap clamp to exactly one tick, same-ms no-op; expiry flips only open past-deadline contracts incl. the inclusive edge; finalize awards cash+rep exactly once with right remaining fuel and does nothing pre-arrival or without route info; spawn tops up to target via the shared builder — deterministic for an injected RNG); `lib/game/seed.test.ts` gains generateContract coverage (formula, window, determinism, deadlineOffsetMs, buildSeed ≡ builder output) and the simState assertion now includes lastRealTickMs; new `app/api/sim/route.test.ts` (200 shape, clamp to [1,60], pause freeze across ticks, 400 matrix); `lib/db/client.test.ts` gains a legacy-DB migration test; e2e — new `e2e/sim.spec.ts`: scale-60 clock advance bounded per tick by the clamp, exact freeze while paused, expiry of a hook-shortened deadline + market staying ≥ target (both mutate and create hook modes), POST /api/sim validation/clamping +- **Known interaction with v0.6.0 e2e**: `e2e/state.spec.ts` restart-persistence test previously asserted the sim clock was byte-identical across a container restart — impossible under tick-on-read by design (every GET advances ≤ one clamped tick). The assertion now checks persistence properly: account/planes/contracts still byte-identical, and post-restart `clockMs ≥ before` bounded by `MAX_TICK_ELAPSED_MS × timeScale`. `e2e/api.spec.ts` endpoint list updated to four +- Verified: unit suite 47 → 86 green; `tsc --noEmit` clean; e2e 11 → 16 green against the rebuilt compose instance (incl. restart-persistence); live curl of POST /api/sim + GET /api/state shows the clock advancing at scale 60 then frozen on pause + ## 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) diff --git a/app/api/sim/route.test.ts b/app/api/sim/route.test.ts new file mode 100644 index 0000000..a245a53 --- /dev/null +++ b/app/api/sim/route.test.ts @@ -0,0 +1,89 @@ +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 { POST } from './route'; + +let dir: string; + +beforeAll(async () => { + // Same pattern as app/api/state/route.test.ts: point the DB singleton at a + // throwaway file (fresh module registry per test file → fresh singleton). + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) })); +const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text })); + +describe('POST /api/sim', () => { + it('returns the current simState for an empty body (tick + no-op)', async () => { + const res = await post({}); + expect(res.status).toBe(200); + const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean }; + expect(typeof body.clockMs).toBe('number'); + expect(body.timeScale).toBe(1); // seeded default + expect(body.paused).toBe(false); + }); + + it('sets and clamps timeScale into [1, 60]', async () => { + const hi = await post({ timeScale: 500 }); + expect(hi.status).toBe(200); + expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(60); + + const lo = await post({ timeScale: 0.1 }); + expect(lo.status).toBe(200); + expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1); + + const mid = await post({ timeScale: 37 }); + expect(mid.status).toBe(200); + expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37); + + // Restore the default for later tests. + await post({ timeScale: 1 }); + }); + + it('toggles paused and freezes the clock across ticks', async () => { + const pause = await post({ paused: true }); + expect(pause.status).toBe(200); + expect(((await pause.json()) as { paused: boolean }).paused).toBe(true); + + // Two reads while paused → identical clock (tick-on-read advances nothing). + const a = await post({}); + const b = await post({}); + expect(((await a.json()) as { clockMs: number }).clockMs).toBe( + ((await b.json()) as { clockMs: number }).clockMs, + ); + + const resume = await post({ paused: false }); + expect(resume.status).toBe(200); + expect(((await resume.json()) as { paused: boolean }).paused).toBe(false); + }); + + it('applies both fields in one call', async () => { + const res = await post({ timeScale: 60, paused: true }); + expect(res.status).toBe(200); + const body = (await res.json()) as { timeScale: number; paused: boolean }; + expect(body.timeScale).toBe(60); + expect(body.paused).toBe(true); + await post({ timeScale: 1, paused: false }); // restore + }); + + it('rejects invalid input with 400', async () => { + for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + + for (const text of ['nope{', '[1,2]', '"str"', 'null']) { + const res = await postRaw(text); + expect(res.status).toBe(400); + } + }); +}); diff --git a/app/api/sim/route.ts b/app/api/sim/route.ts new file mode 100644 index 0000000..2466ee1 --- /dev/null +++ b/app/api/sim/route.ts @@ -0,0 +1,42 @@ +import { applyTimeControls } from '@/lib/sim/engine'; + +// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–60, paused?: boolean }. +// Ticks the clock first (so the response reflects current game time), then applies +// and persists the new controls. Returns the updated simState. + +export async function POST(req: Request) { + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'timeScale' && key !== 'paused') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) { + return Response.json({ error: 'timeScale must be a finite number (1–60)' }, { status: 400 }); + } + if (input.paused !== undefined && typeof input.paused !== 'boolean') { + return Response.json({ error: 'paused must be a boolean' }, { status: 400 }); + } + + try { + const sim = await applyTimeControls({ + timeScale: input.timeScale as number | undefined, + paused: input.paused as boolean | undefined, + }); + return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused }); + } catch (err) { + console.error('failed to apply sim controls', err); + return Response.json({ error: 'failed to apply sim controls' }, { status: 500 }); + } +} diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 3a6af5d..3010458 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,7 +1,11 @@ +import { tickNow } from '@/lib/sim/engine'; import { getStateSnapshot } from '@/lib/game/state'; export async function GET() { try { + // Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every + // read reflects current game time (expiry/finalize/spawn run inside the tick). + await tickNow(); return Response.json(await getStateSnapshot()); } catch (err) { console.error('failed to load game state', err); diff --git a/app/api/test/expire-contract/route.ts b/app/api/test/expire-contract/route.ts new file mode 100644 index 0000000..ef7295b --- /dev/null +++ b/app/api/test/expire-contract/route.ts @@ -0,0 +1,74 @@ +import { randomInt, randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { getGameDb, persist } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; +import { loadAirportsGeoJson } from '@/lib/airports'; +import { generateContract, mulberry32 } from '@/lib/game/seed'; +import { tickNow } from '@/lib/sim/engine'; + +// TEST-ONLY hook (v0.7.0) — lets the e2e suite observe contract expiry without +// waiting hours of game time. Disabled unless FLIGHT_TEST_HOOKS=1, which is set in +// docker-compose.yml for the e2e instance only; every other deployment 404s here. +// +// POST /api/test/expire-contract +// body { contractId?: string } +// - with contractId: moves that OPEN contract's deadline to now + HOOK_DEADLINE_MS +// (game time) so the next fast tick expires it; 404 if unknown/not open. +// - without: creates a fresh open contract with the same short deadline. + +const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (~0.2 s real at 60×) + +export async function POST(req: Request) { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + let body: unknown = {}; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const contractId = (body as Record).contractId; + if (contractId !== undefined && typeof contractId !== 'string') { + return Response.json({ error: 'contractId must be a string' }, { status: 400 }); + } + + try { + const inst = await getGameDb(); + const sim = await tickNow(); // clock is current before we set the deadline + const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS); + + if (typeof contractId === 'string') { + const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)); + const row = rows[0]; + if (!row || row.status !== 'open') { + return Response.json({ error: 'contract not found or not open' }, { status: 404 }); + } + await inst.db.update(schema.contracts).set({ deadlineSimTime }).where(eq(schema.contracts.id, contractId)); + await persist(inst); + const updated = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)))[0]; + return Response.json(updated ?? row); + } + + 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, + })); + const rng = mulberry32(randomInt(0, 2 ** 31)); + const created = generateContract({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS }); + await inst.db.insert(schema.contracts).values(created); + await persist(inst); + return Response.json(created, { status: 201 }); + } catch (err) { + console.error('test hook failed', err); + return Response.json({ error: 'test hook failed' }, { status: 500 }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index b476d6d..b1af854 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: # 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" + environment: + # e2e-only affordance (v0.7.0): enables POST /api/test/expire-contract so the + # sim suite can force a near-term contract expiry; 404 without this var. + FLIGHT_TEST_HOOKS: "1" networks: - dev_net labels: diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 40c7761..9ad969c 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 all three endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all four 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/state', '/api/version']); + expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/sim', '/api/state', '/api/version']); }); diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts new file mode 100644 index 0000000..32c22a7 --- /dev/null +++ b/e2e/sim.spec.ts @@ -0,0 +1,153 @@ +import { test, expect, type Page } from '@playwright/test'; + +// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a +// raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS +// hook enabled in docker-compose.yml), and market top-up. + +// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts). +const MAX_TICK_ELAPSED_MS = 2_000; +const SCALE = 60; +const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60 +// Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts. +const MARKET_TARGET = 10; + +interface SimState { + clockMs: number; + timeScale: number; + paused: boolean; +} + +interface StateBody { + account: { cash: number; reputation: number }; + simState: SimState; + planes: Array>; + contracts: Array<{ id: string; status: string; deadlineSimTime: number }>; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function postSim(page: Page, body: unknown) { + return page.request.post('/api/sim', { data: body }); +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.status()).toBe(200); + return (await res.json()) as StateBody; +} + +// Leave the live instance in a sane state for the other specs (scale 1, running). +test.afterEach(async ({ page }) => { + const res = await postSim(page, { timeScale: 1, paused: false }); + expect(res.status()).toBe(200); +}); + +test('timeScale 60 advances the clock by roughly real-time × 60 between polls', async ({ page }) => { + const post = await postSim(page, { timeScale: SCALE }); + expect(post.status()).toBe(200); + const s1 = (await post.json()) as SimState; + expect(s1.timeScale).toBe(SCALE); + expect(s1.paused).toBe(false); + + let prev = s1.clockMs; + for (let i = 0; i < 3; i++) { + await sleep(1_000); // one real second ≈ 60 game seconds at scale 60 — well under the clamp + const body = await getState(page); + const now = body.simState.clockMs; + expect(now).toBeGreaterThan(prev); // strictly advances while actively polled + expect(now - prev).toBeGreaterThanOrEqual(30_000); // ≥ ~500 ms real × 60 + expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick + prev = now; + } +}); + +test('paused freezes the clock exactly across polls', async ({ page }) => { + const post = await postSim(page, { timeScale: SCALE, paused: true }); + expect(post.status()).toBe(200); + const s1 = (await post.json()) as SimState; + expect(s1.paused).toBe(true); + + for (let i = 0; i < 3; i++) { + await sleep(500); + const body = await getState(page); + expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused + } +}); + +test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => { + test.setTimeout(60_000); + + const before = await getState(page); + const victim = before.contracts.find((c) => c.status === 'open'); + expect(victim).toBeDefined(); // steady state always has open contracts + + // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open + // contract's deadline ~10 game-seconds out so expiry is observable at scale 60. + const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } }); + expect(hook.status()).toBe(200); + const moved = (await hook.json()) as { id: string; deadlineSimTime: number }; + expect(moved.id).toBe(victim!.id); + + await postSim(page, { timeScale: SCALE }); + + let expiredBody: StateBody | undefined; + await expect + .poll(async () => { + const body = await getState(page); + if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body; + return !!expiredBody; + }, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' }) + .toBe(true); + + const after = expiredBody!; + expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired'); + // Expired contracts leave the available set… + expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false); + // …and spawn tops the market back up to the target — never below it. + const openCount = after.contracts.filter((c) => c.status === 'open').length; + expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); +}); + +test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => { + test.setTimeout(60_000); + + const created = await page.request.post('/api/test/expire-contract'); + expect(created.status()).toBe(201); + const c = (await created.json()) as { id: string; status: string }; + expect(c.status).toBe('open'); + + await postSim(page, { timeScale: SCALE }); + + let expiredBody: StateBody | undefined; + await expect + .poll(async () => { + const body = await getState(page); + if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body; + return !!expiredBody; + }, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' }) + .toBe(true); + + const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length; + expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too +}); + +test('POST /api/sim validates and clamps its input', async ({ page }) => { + const clampHigh = await postSim(page, { timeScale: 500 }); + expect(clampHigh.status()).toBe(200); + expect(((await clampHigh.json()) as SimState).timeScale).toBe(60); + + const clampLow = await postSim(page, { timeScale: 0.1 }); + expect(clampLow.status()).toBe(200); + expect(((await clampLow.json()) as SimState).timeScale).toBe(1); + + for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) { + const res = await postSim(page, body); + expect(res.status()).toBe(400); + } + const notJson = await page.request.post('/api/sim', { data: 'nope{' }); + expect(notJson.status()).toBe(400); + + // Empty body is a valid no-op (just ticks + returns current state). + const empty = await postSim(page, {}); + expect(empty.status()).toBe(200); +}); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 24b7cd1..0a4661a 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -65,9 +65,16 @@ test('state survives a container restart (sim clock + account persisted)', async 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); + // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock + // persisted across sessions"): account and entity ids are byte-identical. + // v0.7.0 tick-on-read caveat: every GET advances the clock by at most one + // clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the + // post-restart clock may be ahead — but never by more than that bound. + expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs); + const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts + expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual( + MAX_TICK_ELAPSED_MS * a.simState.timeScale, + ); 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()); diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index 7ec2ec7..11dee8d 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -1,10 +1,47 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import initSqlJs from 'sql.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { openGameDb, persist } from './client'; import * as schema from './schema'; +// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. +const LEGACY_DDL = ` +CREATE TABLE 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 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 account ( + cash REAL NOT NULL, + reputation REAL NOT NULL +); +CREATE TABLE sim_state ( + clock_ms INTEGER NOT NULL, + time_scale REAL NOT NULL, + paused INTEGER NOT NULL +); +`; + let dir: string; beforeAll(async () => { @@ -66,4 +103,29 @@ describe('openGameDb', () => { const reloaded = await openGameDb(file); expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345); }); + + it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { + // Build a legacy file with the v0.6.0 schema (no departure_sim_time / last_real_tick_ms). + const SQL = await initSqlJs(); + const raw = new SQL.Database(); + raw.exec(LEGACY_DDL); + raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); + raw.exec(`INSERT INTO account VALUES (20000, 10)`); + raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)'); + const file = path.join(dir, 'legacy.db'); + await fs.writeFile(file, Buffer.from(raw.export())); + + const inst = await openGameDb(file); + + // Not re-seeded (account row exists)… + expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); + // …but the new columns exist and are usable… + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane?.departureSimTime ?? null).toBeNull(); + const sim = (await inst.db.select().from(schema.simState))[0]; + expect(sim?.clockMs).toBe(1_750_000_000_000); + // …and the ALTER-default 0 was fixed up to a real timestamp so the first + // tick-on-read resumes cleanly instead of taking one clamped catch-up jump. + expect(sim?.lastRealTickMs).toBeGreaterThan(0); + }); }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 0f8960b..32d8389 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS planes ( location_icao TEXT NOT NULL, contract_id TEXT, lat REAL NOT NULL, - lon REAL NOT NULL + lon REAL NOT NULL, + departure_sim_time INTEGER ); CREATE TABLE IF NOT EXISTS contracts ( id TEXT PRIMARY KEY NOT NULL, @@ -50,10 +51,25 @@ CREATE TABLE IF NOT EXISTS account ( CREATE TABLE IF NOT EXISTS sim_state ( clock_ms INTEGER NOT NULL, time_scale REAL NOT NULL, - paused INTEGER NOT NULL + paused INTEGER NOT NULL, + last_real_tick_ms INTEGER NOT NULL DEFAULT 0 ); `; +// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF +// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs +// already have everything from DDL above and this is a no-op for them. +function migrate(raw: RawSqlJsDatabase): void { + const cols = (table: string): Set => + new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1]))); + if (!cols('sim_state').has('last_real_tick_ms')) { + raw.exec('ALTER TABLE sim_state ADD COLUMN last_real_tick_ms INTEGER NOT NULL DEFAULT 0'); + } + if (!cols('planes').has('departure_sim_time')) { + raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER'); + } +} + // 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 { @@ -70,6 +86,7 @@ async function open(file: string): Promise { } const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); raw.exec(DDL); + migrate(raw); const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; @@ -78,6 +95,15 @@ async function open(file: string): Promise { if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { await seedEmpty(instance); } + + // v0.7.0 fixup: pre-v0.7.0 DBs got last_real_tick_ms = 0 from the ALTER TABLE + // default. Reset it to "now" so the first tick-on-read resumes cleanly instead + // of taking one clamped catch-up jump. Fresh seeds already carry a real value. + const simRows = raw.exec('SELECT clock_ms, last_real_tick_ms FROM sim_state'); + if (simRows[0]?.values?.length && Number(simRows[0].values[0][1]) === 0) { + raw.exec(`UPDATE sim_state SET last_real_tick_ms = ${Date.now()}`); + await persist(instance); + } return instance; } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e8030f2..eb566a1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -14,6 +14,10 @@ export const planes = sqliteTable('planes', { contractId: text('contract_id'), lat: real('lat').notNull(), lon: real('lon').notNull(), + // Sim-clock departure time of the current flight (null when available). While + // flying, `fuel` is frozen at its departure value — live position/fuel are pure + // functions of the clock (lib/sim/movement.ts), recomputed on read. + departureSimTime: integer('departure_sim_time'), }); export const contracts = sqliteTable('contracts', { @@ -24,7 +28,9 @@ export const contracts = sqliteTable('contracts', { cargoType: text('cargo_type').notNull(), cargoCount: integer('cargo_count').notNull(), deadlineSimTime: integer('deadline_sim_time').notNull(), - status: text('status', { enum: ['open', 'accepted', 'completed'] }).notNull(), + // 'expired' = an open contract whose deadline passed (v0.7.0). Accepted/ + // in-flight contracts never expire — they complete. + status: text('status', { enum: ['open', 'accepted', 'completed', 'expired'] }).notNull(), planeId: text('plane_id'), createdSimTime: integer('created_sim_time').notNull(), }); @@ -38,6 +44,9 @@ export const simState = sqliteTable('sim_state', { clockMs: integer('clock_ms').notNull(), timeScale: real('time_scale').notNull(), paused: integer('paused', { mode: 'boolean' }).notNull(), + // Real-time (Date.now()) of the last tick — the tick-on-read engine advances + // clockMs by (now - lastRealTickMs) × timeScale, clamped per tick. + lastRealTickMs: integer('last_real_tick_ms').notNull(), }); export type PlaneRow = typeof planes.$inferSelect; diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 512a590..e05c581 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -6,6 +6,7 @@ import { buildSeed, CONTRACT_COUNT_MAX, CONTRACT_COUNT_MIN, + generateContract, HUB_ICAO, mulberry32, REWARD_PER_KM, @@ -88,7 +89,8 @@ describe('buildSeed', () => { 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 }); + // lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0. + expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW }); }); it('is fully deterministic for a fixed RNG and inputs', () => { @@ -108,3 +110,57 @@ describe('buildSeed', () => { expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000)); }); }); + +describe('generateContract (shared seed/spawn builder)', () => { + it('builds an open contract between distinct fixture airports with the reward formula', () => { + const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); + + expect(c.id).toBe('c-x'); + expect(c.status).toBe('open'); + expect(c.planeId).toBeNull(); + expect(c.createdSimTime).toBe(NOW); + const icaoSet = new Set(AIRPORTS.map((a) => a.icao)); + expect(icaoSet.has(c.originIcao)).toBe(true); + expect(icaoSet.has(c.destIcao)).toBe(true); + expect(c.originIcao).not.toBe(c.destIcao); + + 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))); + + // Same 6–72h game-time deadline window as the seed. + expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); + expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000); + }); + + it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => { + const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); + const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); + expect(a).toEqual(b); + + const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 }); + expect(c.id).toBe('c-2'); + expect(c.createdSimTime).toBe(NOW + 1000); + expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract + }); + + it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => { + const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 }); + expect(c.deadlineSimTime).toBe(NOW + 10_000); + }); + + it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => { + const rngA = mulberry32(42); + const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1)); + const expected: string[] = []; + for (let i = 0; i < count; i++) { + expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW }))); + } + const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); + expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected); + }); + + it('rejects an airport list too small to form a route', () => { + expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow(); + }); +}); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index aac9477..70d7136 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -57,7 +57,7 @@ export interface SeedData { plane: SeedPlane; contracts: SeedContract[]; account: { cash: number; reputation: number }; - simState: { clockMs: number; timeScale: number; paused: false }; + simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number }; } // --- Tunables (documented so future revenue/cost work can build on them) --- @@ -73,6 +73,10 @@ export const HUB_ICAO = 'EDDF'; // Frankfurt Main export const CONTRACT_COUNT_MIN = 8; export const CONTRACT_COUNT_MAX = 12; // inclusive +// Market target (v0.7.0): the sim engine tops open contracts back up to this +// count after expiries, so the job board never runs dry. +export const MARKET_TARGET_OPEN_CONTRACTS = 10; + // 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; @@ -88,6 +92,50 @@ export function rewardForDistanceKm(km: number): number { return Math.round(BASE_REWARD + REWARD_PER_KM * km); } +// Shared contract generator, used by BOTH the first-run seed and the runtime +// market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random +// distinct airports from the dataset, distance-based reward, cargo flavor, +// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by +// the e2e test hook to force a near-term expiry). The RNG call order per contract +// is fixed: origin, dest, cargoType, cargoCount, [deadline window]. +export function generateContract(input: { + id: string; + airports: SeedAirport[]; + rng: Rng; + nowMs: number; + deadlineOffsetMs?: number; +}): SeedContract { + const { airports, rng, nowMs } = input; + if (airports.length < 2) throw new Error('contract generation needs at least two airports'); + + 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 = + input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; + + return { + id: input.id, + originIcao: origin.icao, + destIcao: dest.icao, + reward: rewardForDistanceKm(km), + cargoType, + cargoCount, + deadlineSimTime: Math.round(nowMs + deadlineMs), + status: 'open', + planeId: null, + createdSimTime: nowMs, + }; +} + function pickHub(airports: SeedAirport[]): SeedAirport { return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0]; } @@ -116,41 +164,19 @@ export function buildSeed(input: { }; const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1)); + // Stable index-based ids for the seed set; runtime-generated entities must use + // random UUIDs to avoid colliding with these. 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, - }); + contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs })); } return { plane, contracts, account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, - simState: { clockMs: nowMs, timeScale: 1, paused: false }, + // lastRealTickMs starts at the seed moment so the first tick-on-read advances + // by ~0 instead of treating "time since install" as elapsed play time. + simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs }, }; } diff --git a/lib/game/state.ts b/lib/game/state.ts index 039ccf8..816a557 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -1,6 +1,9 @@ import { loadAirportsGeoJson } from '@/lib/airports'; +import { haversineKm } from '@/lib/geo'; import { getGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; +import { PLANE_TYPES } from './catalog'; +import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement'; // Read-only game-state snapshot for client polling (GET /api/state). @@ -27,7 +30,7 @@ export interface ContractState { cargoType: string; cargoCount: number; deadlineSimTime: number; - status: 'open' | 'accepted' | 'completed'; + status: 'open' | 'accepted' | 'completed' | 'expired'; planeId: string | null; createdSimTime: number; } @@ -57,22 +60,42 @@ export async function getStateSnapshot(): Promise { const fc = await loadAirportsGeoJson(); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao; + const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); + + // Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its + // departure values — live position and fuel are pure functions of the clock. + const contractById = new Map(contractRows.map((c) => [c.id, c])); + const livePlane = (p: schema.PlaneRow) => { + if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null; + const contract = contractById.get(p.contractId); + const type = PLANE_TYPES[p.model]; + const from = contract ? coordsByIcao.get(contract.originIcao) : undefined; + const to = contract ? coordsByIcao.get(contract.destIcao) : undefined; + if (!contract || !type || !from || !to) return null; + const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); + const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); + const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); + return { ...pos, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) }; + }; 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, - })), + planes: planeRows.map((p) => { + const live = livePlane(p); + return { + id: p.id, + model: p.model, + status: p.status, + fuel: live?.fuel ?? p.fuel, + maxFuel: p.maxFuel, + locationIcao: p.locationIcao, + locationName: nameOf(p.locationIcao), + contractId: p.contractId, + lat: live?.lat ?? p.lat, + lon: live?.lon ?? p.lon, + }; + }), contracts: contractRows.map((c) => ({ id: c.id, originIcao: c.originIcao, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index b0758b7..62f0389 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the three public endpoints', () => { + it('documents exactly the four public endpoints', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ '/api/airports', + '/api/sim', '/api/state', '/api/version', ]); @@ -94,5 +95,31 @@ describe('OpenAPI spec', () => { const sim = openApiSpec.components.schemas.SimState.properties; expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']); + + const contractStatus = openApiSpec.components.schemas.ContractState.properties.status; + expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']); + }); + + it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => { + const post = openApiSpec.paths['/api/sim'].post; + expect(post.operationId).toBe('setSimControls'); + + const requestSchema = post.requestBody.content['application/json'].schema; + expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest'); + + const ok = post.responses[200].content['application/json'].schema; + expect(ok.$ref).toBe('#/components/schemas/SimState'); + const bad = post.responses[400].content['application/json'].schema; + expect(bad.$ref).toBe('#/components/schemas/Error'); + + const controls = openApiSpec.components.schemas.SimControlsRequest.properties; + expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']); + expect(controls.timeScale.type).toBe('number'); + expect(controls.timeScale.minimum).toBe(1); + expect(controls.timeScale.maximum).toBe(60); + expect(controls.paused.type).toBe('boolean'); + + const error = openApiSpec.components.schemas.Error.properties; + expect(Object.keys(error)).toEqual(['error']); }); }); diff --git a/lib/openapi.ts b/lib/openapi.ts index 0e85f4b..17cf376 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -37,7 +37,7 @@ export const openApiSpec = { get: { summary: 'Game state snapshot', description: - 'Full current game state for client polling — account, sim clock, planes and contracts. Read-only.', + 'Full current game state for client polling — account, sim clock, planes and contracts. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.', operationId: 'getState', responses: { 200: { @@ -51,6 +51,40 @@ export const openApiSpec = { }, }, }, + '/api/sim': { + post: { + summary: 'Set sim time controls', + description: + 'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; contract expiry, arrival finalization and market spawn run inside that tick.', + operationId: 'setSimControls', + requestBody: { + required: false, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SimControlsRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Updated sim state', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SimState' }, + }, + }, + }, + 400: { + description: 'Invalid body (not an object, unknown field, or wrong types)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, '/api/version': { get: { summary: 'App version', @@ -155,6 +189,26 @@ export const openApiSpec = { }, required: ['clockMs', 'timeScale', 'paused'], }, + SimControlsRequest: { + type: 'object', + description: 'Partial update — omit a field to leave it unchanged.', + properties: { + timeScale: { + type: 'number', + minimum: 1, + maximum: 60, + description: 'Game-time multiplier; values outside [1, 60] are clamped.', + }, + paused: { type: 'boolean' }, + }, + }, + Error: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + required: ['error'], + }, PlaneState: { type: 'object', properties: { @@ -194,7 +248,8 @@ export const openApiSpec = { cargoType: { type: 'string' }, cargoCount: { type: 'integer' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, - status: { enum: ['open', 'accepted', 'completed'] }, + // 'expired' = an open contract whose deadline passed; accepted/in-flight contracts never expire. + status: { enum: ['open', 'accepted', 'completed', 'expired'] }, planeId: { type: ['string', 'null'] }, createdSimTime: { type: 'integer' }, }, diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts new file mode 100644 index 0000000..462a6ce --- /dev/null +++ b/lib/sim/engine.test.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { openGameDb } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; +import { loadAirportsGeoJson } from '@/lib/airports'; +import { haversineKm } from '@/lib/geo'; +import { PLANE_TYPES } from '@/lib/game/catalog'; +import { MARKET_TARGET_OPEN_CONTRACTS, mulberry32 } from '@/lib/game/seed'; +import { arrivalTimeFor } from './movement'; +import { + expireContracts, + finalizeArrivals, + MAX_TICK_ELAPSED_MS, + reputationGainFor, + spawnOpenContracts, + tickOnce, +} from './engine'; + +// Engine tests run against throwaway DB files (openGameDb) with fake timers so +// Date.now() — the only wall-clock input to a tick — is fully controlled. The +// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered +// by app/api/sim/route.test.ts and the e2e suite. + +let dir: string; +const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open + +beforeAll(async () => { + vi.useFakeTimers(); + vi.setSystemTime(T0); + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-')); +}); + +afterAll(async () => { + vi.useRealTimers(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function freshDb(name: string) { + vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now" + return openGameDb(path.join(dir, name)); +} + +describe('tickOnce — clock advance', () => { + it('advances the clock by elapsed real ms × timeScale', async () => { + const inst = await freshDb('scale.db'); + vi.setSystemTime(T0 + 1_000); // one real second later + await inst.db.update(schema.simState).set({ timeScale: 5 }); + + const sim = await tickOnce(inst); + + expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5 + expect(sim.lastRealTickMs).toBe(T0 + 1_000); + }); + + it('freezes the clock while paused but keeps tracking real time', async () => { + const inst = await freshDb('pause.db'); + await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 }); + + vi.setSystemTime(T0 + 3_600_000); // an hour of real pause + let sim = await tickOnce(inst); + expect(sim.clockMs).toBe(T0); // frozen — no advance at all + expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); + + // Unpause: resumes from where it stopped, with NO catch-up for the pause. + await inst.db.update(schema.simState).set({ paused: false }); + vi.setSystemTime(T0 + 3_600_500); + sim = await tickOnce(inst); + expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60 + }); + + it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => { + const inst = await freshDb('clamp.db'); + vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour + + const sim = await tickOnce(inst); // timeScale is the seeded 1 + + expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward + vi.setSystemTime(T0); // reset for later tests in this file + }); + + it('does not advance when two ticks land in the same millisecond', async () => { + const inst = await freshDb('same-ms.db'); + vi.setSystemTime(T0 + 1_000); + const a = await tickOnce(inst); + const b = await tickOnce(inst); // no real time passed since `a` + expect(b.clockMs).toBe(a.clockMs); + }); +}); + +describe('expireContracts', () => { + it('flips only open contracts past their deadline; accepted ones never expire', async () => { + const inst = await freshDb('expiry.db'); + const clock = T0 + 100_000; + + await inst.db.insert(schema.contracts).values([ + { id: 'c-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 }, + { id: 'c-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 }, + { id: 'c-accepted-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'accepted', planeId: null, createdSimTime: T0 }, + ]); + + const n = await expireContracts(inst, clock); + expect(n).toBe(1); + + const byId = new Map((await inst.db.select().from(schema.contracts)).map((c) => [c.id, c.status])); + expect(byId.get('c-open-past')).toBe('expired'); + expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet + expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire + }); + + it('a deadline exactly at the clock counts as expired', async () => { + const inst = await freshDb('expiry-edge.db'); + await inst.db.insert(schema.contracts).values({ + id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, + deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0, + }); + expect(await expireContracts(inst, T0 + 50_000)).toBe(1); // <= is inclusive + }); +}); + +describe('finalizeArrivals', () => { + const FROM = 'EDDF'; + const TO = 'LEMD'; + + async function setupFlight(name: string) { + const inst = await freshDb(name); + const fc = await loadAirportsGeoJson(); + const from = fc.features.find((f) => f.properties.icao === FROM)!.properties; + const to = fc.features.find((f) => f.properties.icao === TO)!.properties; + const km = haversineKm(from.lat, from.lon, to.lat, to.lon); + const type = PLANE_TYPES.atr72; + + // A dispatched flight: contract accepted + bound to the plane, plane flying + // with its fuel frozen at departure (the v0.8.0 dispatch step writes these). + await inst.db.insert(schema.contracts).values({ + id: 'c-flight', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers', + cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: 'plane-starter', createdSimTime: T0, + }); + await inst.db.update(schema.planes) + .set({ status: 'flying', contractId: 'c-flight', departureSimTime: T0, fuel: type.maxFuel }) + .where(eq(schema.planes.id, 'plane-starter')); + + return { inst, from, to, km, type }; + } + + it('does nothing before the arrival time', async () => { + const { inst, km, type } = await setupFlight('finalize-early.db'); + const accountBefore = (await inst.db.select().from(schema.account))[0]!; + + const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival + expect(await finalizeArrivals(inst, clock)).toBe(0); + + const plane = (await inst.db.select().from(schema.planes))[0]; + const account = (await inst.db.select().from(schema.account))[0]!; + expect(plane.status).toBe('flying'); + expect(account.cash).toBe(accountBefore.cash); // no award before landing + }); + + it('awards cash + reputation exactly once and grounds the plane at the destination', async () => { + const { inst, to, km, type } = await setupFlight('finalize-late.db'); + const accountBefore = (await inst.db.select().from(schema.account))[0]!; + + const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival + expect(await finalizeArrivals(inst, clock)).toBe(1); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('available'); + expect(plane.locationIcao).toBe(TO); + expect(plane.lat).toBe(to.lat); + expect(plane.lon).toBe(to.lon); + expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel + expect(plane.departureSimTime).toBeNull(); + + const contract = (await inst.db.select().from(schema.contracts)).find((c) => c.id === 'c-flight')!; + expect(contract.status).toBe('completed'); + + const account = (await inst.db.select().from(schema.account))[0]!; + expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // exact reward + expect(account.reputation).toBeCloseTo( + Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, + 9, + ); + + // A later tick must not award again — the plane is no longer 'flying'. + expect(await finalizeArrivals(inst, clock + 3_600_000)).toBe(0); + const accountAfter = (await inst.db.select().from(schema.account))[0]!; + expect(accountAfter.cash).toBe(account.cash); + expect(accountAfter.reputation).toBe(account.reputation); + }); + + it('leaves planes without route info alone', async () => { + const inst = await freshDb('finalize-noroute.db'); + // Flying plane whose contract row is missing — defensive skip, no crash. + await inst.db.update(schema.planes) + .set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' }); + expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0); + }); +}); + +describe('spawnOpenContracts', () => { + it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => { + const inst = await freshDb('spawn.db'); + const clock = T0 + 123_456; + + // Leave exactly 3 open contracts (the rest of the seeded set is "done"). + const all = await inst.db.select().from(schema.contracts); + for (const c of all.slice(3)) { + await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id)); + } + + const needed = await spawnOpenContracts(inst, clock, mulberry32(7)); + expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3); + + const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open'); + expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS); + + const fc = await loadAirportsGeoJson(); + const icaoSet = new Set(fc.features.map((f) => f.properties.icao)); + for (const c of openRows.slice(3)) { // the spawned ones + expect(c.createdSimTime).toBe(clock); // created at game time, not real time + expect(icaoSet.has(c.originIcao)).toBe(true); + expect(icaoSet.has(c.destIcao)).toBe(true); + expect(c.originIcao).not.toBe(c.destIcao); + expect(c.planeId).toBeNull(); + // Same deadline window as the seed: now + 6–72h game time. + expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1); + expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000); + } + + // Already at target → nothing more to spawn. + expect(await spawnOpenContracts(inst, clock, mulberry32(7))).toBe(0); + }); + + it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => { + const a = await freshDb('spawn-a.db'); + const b = await freshDb('spawn-b.db'); + const clock = T0 + 999; + + // Wipe all contracts in both so both spawn from zero with the same RNG. + for (const inst of [a, b]) { + const rows = await inst.db.select().from(schema.contracts); + for (const r of rows) { + await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, r.id)); + } + } + + expect(await spawnOpenContracts(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS); + expect(await spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS); + + const strip = (rows: schema.ContractRow[]) => + rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); + expect(strip(await a.db.select().from(schema.contracts))).toEqual( + strip(await b.db.select().from(schema.contracts)), + ); + }); +}); + +// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the +// engine composes arrivalTimeFor; tests just need the duration for fixtures). +function travelMs(km: number, speedKmh: number): number { + return (km / speedKmh) * 3_600_000; +} diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts new file mode 100644 index 0000000..f5aecfe --- /dev/null +++ b/lib/sim/engine.ts @@ -0,0 +1,209 @@ +import { randomInt, randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { getGameDb, persist, type GameDb } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; +import { loadAirportsGeoJson } from '@/lib/airports'; +import { haversineKm } from '@/lib/geo'; +import { PLANE_TYPES } from '@/lib/game/catalog'; +import { + generateContract, + MARKET_TARGET_OPEN_CONTRACTS, + mulberry32, + type Rng, + type SeedAirport, +} from '@/lib/game/seed'; +import { arrivalTimeFor, fuelAt } from './movement'; + +// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background +// timer. Every read/action endpoint calls tickNow() first; the clock advances by +// (real ms since last tick) × timeScale, clamped per tick so long absences never +// fast-forward the game. + +// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — +// comfortably above a normal client poll interval (~1 s), so active play advances +// ~real time × scale; small enough that one stale read after an absence moves game +// time by at most 2 s × scale (≤ 2 min even at max scale 60). The sim therefore +// only meaningfully progresses while actively polled. +export const MAX_TICK_ELAPSED_MS = 2_000; + +export const MIN_TIME_SCALE = 1; +export const MAX_TIME_SCALE = 60; + +// Reputation gain per completed contract: BASE + PER_KM × great-circle km, rounded +// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a +// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10. +export const REP_BASE = 1; +export const REP_PER_KM = 0.0005; + +export function reputationGainFor(km: number): number { + return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; +} + +export interface SimControlsInput { + timeScale?: number; + paused?: boolean; +} + +// Serialize ticks across concurrent requests (Next.js route handlers run in the +// same process): each tick re-reads sim_state after the previous one wrote it, so +// no advance is ever applied twice. +let chain: Promise = Promise.resolve(); +function withLock(fn: () => Promise): Promise { + const run = chain.then(fn); + chain = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +// --- Tick core (takes a GameDb so unit tests can inject their own instance) --- + +export async function tickOnce(inst: GameDb): Promise { + const rows = await inst.db.select().from(schema.simState); + const sim = rows[0]; + if (!sim) throw new Error('sim_state row missing'); + + const nowMs = Date.now(); + const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); + // While paused the clock is frozen; lastRealTickMs still tracks real time so + // unpausing resumes cleanly with no catch-up jump for the pause duration. + const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale; + const clockMs = Math.round(sim.clockMs + advanceMs); + + if (advanceMs > 0) { + await expireContracts(inst, clockMs); + await finalizeArrivals(inst, clockMs); + await spawnOpenContracts(inst, clockMs); + } + + // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. + await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs }); + await persist(inst); + const updated = (await inst.db.select().from(schema.simState))[0]; + if (!updated) throw new Error('sim_state row missing after tick'); + return updated; +} + +// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight +// contracts never expire — they complete. Returns the number flipped. +export async function expireContracts(inst: GameDb, clockMs: number): Promise { + const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')); + let n = 0; + for (const c of open) { + if (c.deadlineSimTime <= clockMs) { + await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id)); + n++; + } + } + return n; +} + +// Finalize in-flight planes whose arrival time has passed: award the contract +// reward (cash) + reputation gain exactly once, mark the contract completed, and +// ground the plane at the destination with its remaining fuel. Idempotent — a +// finalized plane leaves the 'flying' state, so it can never be awarded twice. +export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise { + const [planeRows, contractRows] = await Promise.all([ + inst.db.select().from(schema.planes), + inst.db.select().from(schema.contracts), + ]); + + const fc = await loadAirportsGeoJson(); + const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); + const contractById = new Map(contractRows.map((c) => [c.id, c])); + + let n = 0; + for (const p of planeRows) { + if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue; + const contract = contractById.get(p.contractId); + const type = PLANE_TYPES[p.model]; + const from = contract ? byIcao.get(contract.originIcao) : undefined; + const to = contract ? byIcao.get(contract.destIcao) : undefined; + if (!contract || !type || !from || !to) continue; // no route info — leave alone + + const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); + if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; + + // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg burn. + await inst.db.update(schema.planes) + .set({ + status: 'available', + locationIcao: to.icao, + lat: to.lat, + lon: to.lon, + fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1), + departureSimTime: null, + }) + .where(eq(schema.planes.id, p.id)); + await inst.db.update(schema.contracts) + .set({ status: 'completed' }) + .where(eq(schema.contracts.id, contract.id)); + // Fresh account read per award so multiple arrivals in one tick all count. + const fresh = (await inst.db.select().from(schema.account))[0]; + if (!fresh) throw new Error('account row missing'); + await inst.db.update(schema.account).set({ + cash: fresh.cash + contract.reward, + reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100, + }); + n++; + } + return n; +} + +// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the +// SAME builder as the first-run seed (generateContract), so spawned contracts are +// indistinguishable from seeded ones. RNG is injectable for deterministic tests. +export async function spawnOpenContracts( + inst: GameDb, + clockMs: number, + rng?: Rng, +): Promise { + const openCount = ( + await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')) + ).length; + const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount; + if (needed <= 0) return 0; + + const fc = await loadAirportsGeoJson(); + const airports: SeedAirport[] = fc.features.map((f) => ({ + icao: f.properties.icao, + name: f.properties.name, + lat: f.properties.lat, + lon: f.properties.lon, + })); + const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); + + for (let i = 0; i < needed; i++) { + await inst.db.insert(schema.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs })); + } + return needed; +} + +// --- Public API (singleton DB + lock) — call from every read/action endpoint --- + +export function tickNow(): Promise { + return withLock(async () => tickOnce(await getGameDb())); +} + +// Tick first so the returned state reflects current time, then apply the new +// controls. timeScale is clamped to [1, 60]; validation of raw input happens in +// the route handler (400 on non-number / non-boolean). +export function applyTimeControls(input: SimControlsInput): Promise { + return withLock(async () => { + const inst = await getGameDb(); + await tickOnce(inst); + const sets: Partial> = {}; + if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale); + if (input.paused !== undefined) sets.paused = input.paused; + if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets); + await persist(inst); + const updated = (await inst.db.select().from(schema.simState))[0]; + if (!updated) throw new Error('sim_state row missing'); + return updated; + }); +} + +export function clampTimeScale(v: number): number { + return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v)); +} diff --git a/lib/sim/movement.test.ts b/lib/sim/movement.test.ts new file mode 100644 index 0000000..d6b22c4 --- /dev/null +++ b/lib/sim/movement.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { haversineKm } from '@/lib/geo'; +import { arrivalTimeFor, fuelAt, positionAt, progressAt, travelDurationMs } from './movement'; + +// Synthetic in-flight fixtures — no DB needed: everything is a pure function of +// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts. +const DEP = 1_750_000_000_000; // departure sim time (ms) +const SPEED = 510; // km/h +const BURN = 1.75; // L/km + +// Frankfurt → Madrid, real coordinates from the CSV dataset. +const FROM = { lat: 50.0333, lon: 8.5706 }; +const TO = { lat: 40.47, lon: -3.56 }; +const KM = haversineKm(FROM.lat, FROM.lon, TO.lat, TO.lon); // ≈ 2190 km + +describe('travelDurationMs / arrivalTimeFor', () => { + it('duration is distance/speed converted to ms', () => { + expect(travelDurationMs(510, SPEED)).toBe(3_600_000); // one hour at cruise + expect(travelDurationMs(KM, SPEED)).toBeCloseTo((KM / SPEED) * 3_600_000, 9); + }); + + it('rejects a non-positive speed', () => { + expect(() => travelDurationMs(100, 0)).toThrow(); + }); + + it('arrival is departure + duration', () => { + const arrival = arrivalTimeFor(DEP, KM, SPEED); + expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9); + expect(arrival).toBeGreaterThan(DEP); + }); +}); + +describe('progressAt', () => { + const duration = travelDurationMs(KM, SPEED); + + it('is 0 before departure and at departure', () => { + expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0); + expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0); + }); + + it('is linear in the middle of the flight', () => { + // Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few + // ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg. + expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9); + expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9); + }); + + it('is exactly 1 at arrival and stays 1 after', () => { + const arrival = DEP + duration; + expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1); + expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1); + }); + + it('a zero-distance leg is complete immediately', () => { + expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1); + }); +}); + +describe('positionAt (great-circle slerp)', () => { + it('returns the exact endpoints at progress 0 and 1', () => { + expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0)).toEqual({ lat: FROM.lat, lon: FROM.lon }); + expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1)).toEqual({ lat: TO.lat, lon: TO.lon }); + }); + + it('clamps out-of-range progress to the endpoints', () => { + expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, -0.5)).toEqual({ lat: FROM.lat, lon: FROM.lon }); + expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1.5)).toEqual({ lat: TO.lat, lon: TO.lon }); + }); + + it('stays on the great circle: arc lengths add up to the total distance', () => { + for (const t of [0.1, 0.37, 0.5, 0.9]) { + const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, t); + const d1 = haversineKm(FROM.lat, FROM.lon, p.lat, p.lon); + const d2 = haversineKm(p.lat, p.lon, TO.lat, TO.lon); + expect(d1 + d2).toBeCloseTo(KM, 6); // slerp keeps the point on the arc + } + }); + + it('the midpoint is equidistant from both ends', () => { + const m = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0.5); + expect(haversineKm(FROM.lat, FROM.lon, m.lat, m.lon)).toBeCloseTo(KM / 2, 6); + expect(haversineKm(m.lat, m.lon, TO.lat, TO.lon)).toBeCloseTo(KM / 2, 6); + }); + + it('is exact along the equator (great circle = constant latitude)', () => { + for (const t of [0.25, 0.5, 0.75]) { + const p = positionAt(0, -10, 0, 10, t); + expect(p.lat).toBeCloseTo(0, 9); + expect(p.lon).toBeCloseTo(-10 + 20 * t, 9); + } + }); + + it('handles the same-point route without dividing by zero', () => { + const p = positionAt(48.85, 2.35, 48.85, 2.35, 0.5); + expect(p).toEqual({ lat: 48.85, lon: 2.35 }); + }); +}); + +describe('fuelAt', () => { + it('burns fuel proportionally to distance flown', () => { + const start = 6_300; // full ATR tank + expect(fuelAt(start, BURN, KM, 0)).toBe(start); + expect(fuelAt(start, BURN, KM, 0.5)).toBeCloseTo(start - BURN * KM / 2, 9); + expect(fuelAt(start, BURN, KM, 1)).toBeCloseTo(start - BURN * KM, 9); + }); + + it('clamps at zero instead of going negative', () => { + expect(fuelAt(100, BURN, 1_000, 1)).toBe(0); // needs 1750 L, has 100 + expect(fuelAt(100, BURN, 1_000, 0.01)).toBeCloseTo(100 - 17.5, 9); + }); + + it('composes with progressAt for a just-before-arrival snapshot', () => { + const duration = travelDurationMs(KM, SPEED); + const clock = DEP + duration - 1; // one ms before arrival + const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock)); + expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate + expect(fuelAt(6_300, BURN, KM, progressAt(DEP, KM, SPEED, clock))).toBeCloseTo( + 6_300 - BURN * KM * (1 - 1 / duration), + 6, + ); + }); +}); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts new file mode 100644 index 0000000..029b7a6 --- /dev/null +++ b/lib/sim/movement.ts @@ -0,0 +1,72 @@ +// Pure analytic flight math (v0.7.0). Position and fuel of an in-flight plane are +// functions of the sim clock — no incremental stepping, so a tick can jump any +// distance in game time without losing accuracy. All inputs are explicit; there +// is no DB/IO here, which keeps this fully unit-testable with synthetic routes. + +const toRad = (d: number) => (d * Math.PI) / 180; +const toDeg = (r: number) => (r * 180) / Math.PI; + +// Flight duration in ms for a leg of distanceKm at speedKmh. +export function travelDurationMs(distanceKm: number, speedKmh: number): number { + if (!(speedKmh > 0)) throw new Error('speedKmh must be > 0'); + return (distanceKm / speedKmh) * 3_600_000; +} + +// Sim-clock time at which the leg completes. +export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number { + return departureSimTime + travelDurationMs(distanceKm, speedKmh); +} + +// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before +// departure and 1 after arrival. A zero-distance leg is complete immediately. +export function progressAt( + departureSimTime: number, + distanceKm: number, + speedKmh: number, + clockMs: number, +): number { + const duration = travelDurationMs(distanceKm, speedKmh); + if (duration <= 0) return 1; + return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration)); +} + +// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1] +// (slerp on the unit sphere). Exact endpoints: t=0 → from, t=1 → to. +export function positionAt( + fromLat: number, + fromLon: number, + toLat: number, + toLon: number, + t: number, +): { lat: number; lon: number } { + if (t <= 0) return { lat: fromLat, lon: fromLon }; + if (t >= 1) return { lat: toLat, lon: toLon }; + + const vec = (lat: number, lon: number): [number, number, number] => { + const phi = toRad(lat); + const lambda = toRad(lon); + return [Math.cos(phi) * Math.cos(lambda), Math.cos(phi) * Math.sin(lambda), Math.sin(phi)]; + }; + const a = vec(fromLat, fromLon); + const b = vec(toLat, toLon); + + const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])); + const theta = Math.acos(dot); + if (theta < 1e-9) return { lat: fromLat, lon: fromLon }; // same point + + const sinTheta = Math.sin(theta); + const wA = Math.sin((1 - t) * theta) / sinTheta; + const wB = Math.sin(t * theta) / sinTheta; + const v: [number, number, number] = [a[0] * wA + b[0] * wB, a[1] * wA + b[1] * wB, a[2] * wA + b[2] * wB]; + + return { + lat: toDeg(Math.asin(Math.max(-1, Math.min(1, v[2])))), + lon: toDeg(Math.atan2(v[1], v[0])), + }; +} + +// Fuel remaining after flying `progress` of a leg of distanceKm. Clamped at 0 — +// range exhaustion is not modeled yet (fuel management/refueling comes later). +export function fuelAt(startFuel: number, burnPerKm: number, distanceKm: number, progress: number): number { + return Math.max(0, startFuel - burnPerKm * distanceKm * progress); +} diff --git a/package.json b/package.json index abf1a77..e671d9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.6.0", + "version": "0.7.0", "private": true, "scripts": { "dev": "next dev -p 3000",