import { test, expect, type Page } from '@playwright/test'; // v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it // can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one // pool, at EDDF. import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed'; import { authenticate } from './auth'; // v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. test.beforeEach(async ({ page }) => { await authenticate(page); }); // v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully // grounded fleet freezes the clock, 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 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); } // A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock // runs at FULL scale for tests that need fast-forwarding. 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 world clock (#135)', 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 c0 = s.simState.clockMs; await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below const after = await getState(page); expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0); }); test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => { // Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and // the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements). await expect .poll(async () => { const res = await page.request.get('/api/state'); if (res.status() !== 200) return 'down'; const body = (await res.json()) as StateBody; return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy'; }, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' }) .toBe('idle'); const before = await getState(page); expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true); const clockBefore = before.simState.clockMs; // Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass — // ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits // only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this // reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around // its removal, so the market can never empty; cap the passes to bound runtime either way (a // zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the // board comes back on the very next tick. for (let pass = 0; pass < 3; pass++) { const s = await getState(page); const open = s.packages.filter((p) => p.status === 'open'); if (open.length === 0) break; // drained — the pre-fix soft-lock state for (const p of open) { expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200); } } // Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the // pool right away — within ~15 s of real time, with headroom for dind slowness… let topped: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.some((p) => p.status === 'open')) topped = body; return !!topped; }, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' }) .toBe(true); const after = topped!; expect(after.packages.filter((p) => p.status === 'open').length).toBeGreaterThan(0); // starvation fixed… expect(after.planes.every((p) => p.status === 'available'), 'no plane may have taken off during the drain').toBe(true); // …with no plane flying… expect(after.simState.clockMs, 'the grounded clock must stay exactly frozen while the market refills').toBe(clockBefore); // …and the world still 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 job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => { 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): ages the job's createdRealMs past its // lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no // scale or clock activation needed. 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; status: string }; expect(moved.id).toBe(victim!.id); expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired'); // Expired jobs leave the market… let after: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body; return !!after; }, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' }) .toBe(true); // …and the spawner refills the pool back up — never below its target. expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market }); test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => { 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 }; // The hook ages the job before its final tick, so it never even appears open in /api/state. expect(c.status).toBe('expired'); let after: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body; return !!after; }, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' }) .toBe(true); expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too }); 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); });