import { test, expect, type Page } from '@playwright/test'; // v0.7.0 sim engine against the live origin: tick-on-read clock advance at a // raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS // hook enabled in docker-compose.yml), and market top-up. // Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts). const MAX_TICK_ELAPSED_MS = 2_000; const SCALE = 60; const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60 // Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts. const MARKET_TARGET = 10; interface SimState { clockMs: number; timeScale: number; paused: boolean; } interface StateBody { account: { cash: number; reputation: number }; simState: SimState; planes: Array>; contracts: Array<{ id: string; status: string; deadlineSimTime: number }>; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function postSim(page: Page, body: unknown) { return page.request.post('/api/sim', { data: body }); } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); expect(res.status()).toBe(200); return (await res.json()) as StateBody; } // Leave the live instance in a sane state for the other specs (scale 1, running). test.afterEach(async ({ page }) => { const res = await postSim(page, { timeScale: 1, paused: false }); expect(res.status()).toBe(200); }); test('timeScale 60 advances the clock by roughly real-time × 60 between polls', async ({ page }) => { const post = await postSim(page, { timeScale: SCALE }); expect(post.status()).toBe(200); const s1 = (await post.json()) as SimState; expect(s1.timeScale).toBe(SCALE); expect(s1.paused).toBe(false); let prev = s1.clockMs; for (let i = 0; i < 3; i++) { await sleep(1_000); // one real second ≈ 60 game seconds at scale 60 — well under the clamp const body = await getState(page); const now = body.simState.clockMs; expect(now).toBeGreaterThan(prev); // strictly advances while actively polled expect(now - prev).toBeGreaterThanOrEqual(30_000); // ≥ ~500 ms real × 60 expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick prev = now; } }); test('paused freezes the clock exactly across polls', async ({ page }) => { const post = await postSim(page, { timeScale: SCALE, paused: true }); expect(post.status()).toBe(200); const s1 = (await post.json()) as SimState; expect(s1.paused).toBe(true); for (let i = 0; i < 3; i++) { await sleep(500); const body = await getState(page); expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused } }); test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => { test.setTimeout(60_000); const before = await getState(page); const victim = before.contracts.find((c) => c.status === 'open'); expect(victim).toBeDefined(); // steady state always has open contracts // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open // contract's deadline ~10 game-seconds out so expiry is observable at scale 60. const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } }); expect(hook.status()).toBe(200); const moved = (await hook.json()) as { id: string; deadlineSimTime: number }; expect(moved.id).toBe(victim!.id); await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body; return !!expiredBody; }, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' }) .toBe(true); const after = expiredBody!; expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired'); // Expired contracts leave the available set… expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false); // …and spawn tops the market back up to the target — never below it. const openCount = after.contracts.filter((c) => c.status === 'open').length; expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); }); test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => { test.setTimeout(60_000); const created = await page.request.post('/api/test/expire-contract'); expect(created.status()).toBe(201); const c = (await created.json()) as { id: string; status: string }; expect(c.status).toBe('open'); await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body; return !!expiredBody; }, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' }) .toBe(true); const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length; expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too }); test('POST /api/sim validates and clamps its input', async ({ page }) => { const clampHigh = await postSim(page, { timeScale: 500 }); expect(clampHigh.status()).toBe(200); expect(((await clampHigh.json()) as SimState).timeScale).toBe(64); // top of the power-of-two ladder (v0.9.2) 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); });