feat: v0.7.0 — server-authoritative sim engine: tick-on-read clock (time-scale + pause via POST /api/sim), contract expiry + market spawn, analytic plane movement/finalization
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>).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 });
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+2
-2
@@ -13,12 +13,12 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with 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<string, unknown> };
|
||||
expect(spec.info.title).toBe('Flight Ops API');
|
||||
expect(spec.info.version).toBe(pkg.version);
|
||||
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/state', '/api/version']);
|
||||
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/sim', '/api/state', '/api/version']);
|
||||
});
|
||||
|
||||
+153
@@ -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<Record<string, unknown>>;
|
||||
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<StateBody> {
|
||||
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);
|
||||
});
|
||||
+10
-3
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-2
@@ -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<string> =>
|
||||
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<GameDb> {
|
||||
}
|
||||
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<GameDb> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+57
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+55
-29
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
+29
-6
@@ -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<StateSnapshot> {
|
||||
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) => ({
|
||||
planes: planeRows.map((p) => {
|
||||
const live = livePlane(p);
|
||||
return {
|
||||
id: p.id,
|
||||
model: p.model,
|
||||
status: p.status,
|
||||
fuel: p.fuel,
|
||||
fuel: live?.fuel ?? p.fuel,
|
||||
maxFuel: p.maxFuel,
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
})),
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
};
|
||||
}),
|
||||
contracts: contractRows.map((c) => ({
|
||||
id: c.id,
|
||||
originIcao: c.originIcao,
|
||||
|
||||
+28
-1
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
+57
-2
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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<schema.SimStateRow> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<schema.SimStateRow> {
|
||||
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<schema.SimStateRow> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
|
||||
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));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user