import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side // WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly) // restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at // 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×. const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts) const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) interface SimState { clockMs: number; timeScale: number; paused: boolean; } interface StateBody { simState: SimState; planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>; } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); expect(res.ok()).toBeTruthy(); return (await res.json()) as StateBody; } async function postSim(page: Page, body: Record): Promise { const res = await page.request.post('/api/sim', { data: body }); expect(res.status()).toBe(200); } // Set the time scale and VERIFY from the response that it stuck — if something external ever // resets the live instance's controls mid-test, fail loudly here instead of timing out later. // ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any // fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as SimState; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); } // Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight). async function nearestToHub(page: Page): Promise<{ icao: string }> { const res = await page.request.get('/api/airports'); expect(res.ok()).toBeTruthy(); const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon); const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon); if (d < dn) nearest = f.properties; } return { icao: nearest.icao }; } // Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and // reposition plane-starter home via the API so every test starts from the canonical state even // after an interrupted previous run. async function flyHomeViaApi(page: Page): Promise { let s = await getState(page); let plane = s.planes.find((p) => p.id === 'plane-starter')!; if (plane.status === 'flying' || plane.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (plane.status === 'flying') { // Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the // DEPARTURE airport — re-read state after landing before deciding whether a repositioning // flight is needed. await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); s = await getState(page); plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { // ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as // lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours. await setScale(page, SCALE); // ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable // leg when cash allows — no top-up retry needed. const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); } } // Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so // fast-forward + reposition home here; then restore scale=1 / unpaused for later specs. test.afterEach(async ({ page }) => { await flyHomeViaApi(page); await postSim(page, { timeScale: 1, paused: false }); }); test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF const nearest = await nearestToHub(page); // Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000). await setScale(page, SCALE); // Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed). const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } }); expect(flyRes.status()).toBe(200); // Wait for the landing… let landedBody: StateBody | undefined; await expect .poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b; return !!landedBody; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` }) .toBe(true); // …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very // snapshot shows it (the just-landed plane is on ground → slow; ISS-057). expect(landedBody!.simState.paused).toBe(false); expect(landedBody!.simState.timeScale).toBe(1); // A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN // full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000 // above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne) // resumes it. const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(fly2.status()).toBe(200); const resumedBody = (await fly2.json()) as StateBody; expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause… expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed // Let the return leg finish so cleanup starts from a grounded plane. await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available'); });