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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user