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