import type { Browser, BrowserContext, Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { applySession, authenticate, mintPlayerSession } from './auth'; // v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113) // and ownership isolation across two live sessions. Player A is the suite's usual minted local // player; player B is a second account minted through the same hook with a username override (the // route has supported it since v1.1.0 — e2e/auth.ts now forwards it). // // The CI DB volume persists across specs within one run, so this spec must leave no trace later // specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so // an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays — // re-minting is idempotent and test 2 needs a B-owned plane to exist. const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts const SCALE = 1000; // test rate for fast-forwarding legs const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts) const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs interface PlaneState { id: string; status: string; locationIcao: string; maxFuel: number; queue: string[]; ownerId: string; ownerName: string; } interface PlayerSummary { id: string; username: string; cash: number; reputation: number; planeCount: number; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number }; planes: PlaneState[]; players: PlayerSummary[]; } 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; retry briefly instead of throwing. 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); } // Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full. async function setFuelFull(page: Page, planeId: string): Promise { const p = (await getState(page)).planes.find((x) => x.id === planeId)!; expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } // Cancel every queued leg of a plane via the API (works while flying — the server checks no status). async function clearQueueViaApi(page: Page, planeId: string): Promise { for (;;) { const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? []; if (q.length === 0) return; expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200); } } // Closest airport to the hub — a short, full-tank-flyable leg for the clock test. async function nearestToHub(page: Page): Promise { const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; return fc.features .filter((f) => f.properties.icao !== HUB) .map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) })) .sort((x, y) => x.d - y.d)[0].icao; } // Mirrors haversineKm in lib/geo.ts (same formula → identical floats). function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a))); } // Mint the second account in its OWN context — sessions are per-context (headers + localStorage). async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited>; page: Page }> { const ctx = await browser.newContext(); const page = await ctx.newPage(); const sessionId = await mintPlayerSession(page, PLAYER_B); await applySession(page, sessionId); return { ctx, page }; } // Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts. async function healStarterHome(page: Page): Promise { await clearQueueViaApi(page, STARTER); let p = (await getState(page)).planes.find((x) => x.id === STARTER); if (!p) return; // cannot happen — the starter is local's last plane and unsellable await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup if (p.status === 'flying') { // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. await expect .poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), { timeout: 240_000, message: 'waiting for an in-flight starter to land during cleanup', }) .toBe('available'); p = (await getState(page)).planes.find((x) => x.id === STARTER)!; } if (p.locationIcao !== HUB) { const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); expect(flyHome.status()).toBe(200); await expect .poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000, message: 'waiting for the starter to land back at the hub during cleanup', }) .toBe(HUB); } } // Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's // retry) may have left the starter mid-flight, and both tests need a deterministic baseline. async function waitForGroundedFleet(page: Page): Promise { await expect .poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), { timeout: 120_000, message: 'waiting for the fleet to be fully grounded', }) .toBe('idle'); } test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters let bCtx: Awaited> | null = null; test.beforeEach(async ({ page }) => { await authenticate(page); // player A — the seeded local player that owns plane-starter }); test.afterEach(async ({ page }) => { try { // Leave the world exactly as found: starter grounded at the hub, fixed world rate. await healStarterHome(page); await setScale(page, WORLD); } finally { if (bCtx) await bCtx.close(); bCtx = null; } }); // File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs' // single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook // is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design. test.afterAll(async ({ browser }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200); // Verify through the same surface the game uses — mint a local session just for this read. const sessionId = await mintPlayerSession(page); await applySession(page, sessionId); const s = (await getState(page)) as StateBody; expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]); } finally { await ctx.close(); } }); test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => { test.setTimeout(480_000); // self-heal + one short hop at scale 1000 const b = await mintPlayerB(browser); bCtx = b.ctx; await waitForGroundedFleet(page); await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math const dest = await nearestToHub(page); await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest await setFuelFull(page, STARTER); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200); // Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule // is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving. let prev = (await getState(page)).simState.clockMs; for (let i = 0; i < 3; i++) { await sleep(500); const s = await getState(page); // tick-on-read — advances while any plane flies expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne… expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time const now = s.simState.clockMs; expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev); prev = now; } // Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep). await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000, message: "waiting for A's plane to land", }) .toBe('available'); const frozen1 = await getState(page); expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true); await sleep(2_500); // ~2.5 s of real idleness — several background ticks const frozen2 = await getState(page); expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs); }); test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => { const b = await mintPlayerB(browser); bCtx = b.ctx; // Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test). await waitForGroundedFleet(page); const sA = await getState(page); // A's (local session's) view of the shared world const sB = await getState(b.page); // B's view — same world… expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane // Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B. const starter = sA.planes.find((p) => p.id === STARTER)!; expect(starter.ownerId).toBe('local'); expect(starter.ownerName).toBe(LOCAL_USERNAME); const bSummary = sB.players.find((pl) => pl.username === PLAYER_B); expect(bSummary, 'the players summary lists player B').toBeDefined(); const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!; expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is… expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B // A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409: // B's starter is B's only plane, so a missing ownership check would surface as 409 instead). const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } }); expect(flyForeign.status()).toBe(403); expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i); const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } }); expect(sellForeign.status()).toBe(403); // …and B cannot act on A's. const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); expect(flyA.status()).toBe(403); // Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen, // so nothing else can touch either economy between reads. const cA = sA.account.cash; const cB = sB.account.cash; const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } }); expect(grant.status()).toBe(200); const granted = ((await grant.json()) as StateBody).account.cash; expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved… const sBAfter = await getState(b.page); expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB); // The players summary (v1.6.0) is visible in both sessions with per-owner plane counts. for (const s of [sA, sBAfter]) { expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1); expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1); } });