import { test, expect, type Page } from '@playwright/test'; // v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded // fleet, background-tick advance at a raised rate while something is active, package expiry via // the FLIGHT_TEST_HOOKS hooks (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 = 1000; // test rate for fast-forwarding legs const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000 // Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts. const MARKET_TARGET = 10; const STARTER = 'plane-starter'; interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock planes: Array<{ id: string; status: string }>; packages: Array<{ id: string; status: string; deadlineSimTime: number }>; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function getState(page: Page): Promise { // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would // instantly fail any expect.poll callback, so retry briefly instead. for (let attempt = 1; ; attempt++) { const res = await page.request.get('/api/state'); if (res.status() === 200) return (await res.json()) as StateBody; if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); await page.waitForTimeout(500); } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale); } async function setHealth(page: Page, planeId: string, fields: Record): Promise { const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } }); expect(res.status()).toBe(200); } // Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock // advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative // cash assertions; deactivateWorld restores full health and no gate afterwards. async function activateWorld(page: Page): Promise { await setHealth(page, STARTER, { health: 50 }); expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200); } // Shorten the repair gate to "now" — the next advancing tick flips the plane back to available. async function deactivateWorld(page: Page): Promise { await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', { timeout: 30_000, message: 'waiting for the starter to flip back to available', }) .toBe('available'); } // Leave the live instance in a sane state for the other specs (the fixed world rate). test.afterEach(async ({ page }) => { await setScale(page, WORLD); }); test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => { const s = await getState(page); expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true); const frozen = s.simState.clockMs; for (let i = 0; i < 3; i++) { await sleep(500); expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen); } }); test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => { await activateWorld(page); try { await setScale(page, SCALE); let prev = (await getState(page)).simState.clockMs; for (let i = 0; i < 3; i++) { await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp const now = (await getState(page)).simState.clockMs; expect(now).toBeGreaterThan(prev); // strictly advances while something is active expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000 expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick prev = now; } } finally { await deactivateWorld(page); } }); test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => { test.setTimeout(60_000); await activateWorld(page); try { const before = await getState(page); const victim = before.packages.find((p) => p.status === 'open'); expect(victim).toBeDefined(); // steady state always has open packages // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open // package's deadline ~10 game-seconds out so expiry is observable at scale 1000. const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } }); expect(hook.status()).toBe(200); const moved = (await hook.json()) as { id: string; deadlineSimTime: number }; expect(moved.id).toBe(victim!.id); await setScale(page, SCALE); let expiredBody: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body; return !!expiredBody; }, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' }) .toBe(true); const after = expiredBody!; expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired'); // Expired packages leave the available set… expect(after.packages.filter((p) => p.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.packages.filter((p) => p.status === 'open').length; expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); } finally { await deactivateWorld(page); } }); test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => { test.setTimeout(60_000); await activateWorld(page); try { const created = await page.request.post('/api/test/expire-package'); expect(created.status()).toBe(201); const c = (await created.json()) as { id: string; status: string }; expect(c.status).toBe('open'); await setScale(page, SCALE); let expiredBody: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body; return !!expiredBody; }, { timeout: 30_000, message: 'waiting for the hook-created package to expire' }) .toBe(true); const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length; expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too } finally { await deactivateWorld(page); } }); test('POST /api/test/set-timescale validates its input', async ({ page }) => { const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } }); expect(ok.status()).toBe(200); expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) { const res = await page.request.post('/api/test/set-timescale', { data: body }); expect(res.status()).toBe(400); } const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' }); expect(notJson.status()).toBe(400); // Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op). const empty = await page.request.post('/api/test/set-timescale', { data: {} }); expect(empty.status()).toBe(400); });