import { test, expect, type Page } from '@playwright/test'; // v0.8.0 player actions against the live origin: THE full core loop — accept a // contract → dispatch plane-starter → watch it fly at scale 60 → arrival pays the // exact reward + reputation and grounds the plane with reduced fuel — plus the // refuel cost check (folded into the loop, where the tank is genuinely partial) // and the "dispatch without accept" 4xx path. Contracts are created on a known // short route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1 in // docker-compose.yml), with the destination picked as the closest airport to EDDF // from /api/airports so arrival is fast at scale 60. const SCALE = 60; const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts // Mirrors of server constants — kept in sync manually (same pattern as sim.spec.ts). const BURN_PER_KM = 1.75; // atr72, lib/game/catalog.ts const FUEL_PRICE_PER_L = 0.8; // lib/game/catalog.ts const REP_BASE = 1; // lib/sim/engine.ts const REP_PER_KM = 0.0005; interface PlaneState { id: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; departureSimTime: number | null; lat: number; lon: number; } interface ContractState { id: string; originIcao: string; destIcao: string; reward: number; status: 'open' | 'accepted' | 'completed' | 'expired'; planeId: string | null; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; contracts: ContractState[]; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // 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))); } 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; } async function postSim(page: Page, body: unknown) { return page.request.post('/api/sim', { data: body }); } // 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 hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route. async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { const created = await page.request.post('/api/test/create-contract', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, }); expect(created.status()).toBe(201); return (await created.json()) as ContractState; } // Create + accept + dispatch a contract from → to on plane-starter, then poll at // scale 60 until the plane lands at `to`. Used for the main flight and for // restoring the starter plane to EDDF between runs. async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise { const contract = await createContract(page, fromIcao, toIcao); const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); expect(acc.status()).toBe(200); const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(disp.status()).toBe(200); await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const body = await getState(page); const p = body.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === toIcao; }, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` }) .toBe(true); return contract; } test('full core loop: accept → dispatch → fly at 60x → exact reward + rep on arrival', async ({ page }) => { test.setTimeout(240_000); // generous cap — typical run is well under two minutes // Self-heal first: a previous interrupted run may have left the starter plane // mid-flight or at another airport. Let any flight finish, then fly it home so // the loop below starts from the canonical onboarding state (plane at EDDF). const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter'); expect(foundStarter).toBeDefined(); let starter: PlaneState = foundStarter!; if (starter.status === 'flying') { await postSim(page, { timeScale: SCALE }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 90_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); starter = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!; } if (starter.locationIcao !== HUB) { await flyContract(page, starter.locationIcao, HUB); } // Baseline AFTER any healing flight — the exact-cash assertion below must not // include heal rewards. const before = await getState(page); // Closest airport to EDDF from /api/airports → shortest possible arrival time. 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; let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; for (const f of fc.features) { if (f.properties.icao === HUB) continue; if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) { nearest = f.properties; } } const km = haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon); // 1) Accept the hook-created contract — response is the full snapshot. const contract = await createContract(page, HUB, nearest.icao); const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); expect(acc.status()).toBe(200); let snap = (await acc.json()) as StateBody; expect(snap.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted'); // 2) Dispatch plane-starter — snapshot shows it flying with departure set. const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(disp.status()).toBe(200); snap = (await disp.json()) as StateBody; let plane = snap.planes.find((p) => p.id === 'plane-starter')!; expect(plane.status).toBe('flying'); expect(typeof plane.departureSimTime).toBe('number'); // departure set at dispatch time const departureFuel = plane.fuel; // frozen while flying (live fuel is analytic) // Position moves away from the origin across polls at scale 60. await postSim(page, { timeScale: SCALE }); let prevDist = -1; for (let i = 0; i < 3; i++) { await sleep(700); snap = await getState(page); plane = snap.planes.find((p) => p.id === 'plane-starter')!; const distFromOrigin = haversineKm(hub.lat, hub.lon, plane.lat, plane.lon); expect(distFromOrigin).toBeGreaterThan(prevDist); // strictly along the arc prevDist = distFromOrigin; } // 3) Poll until arrival (plane grounded at the destination). let arrived: StateBody | undefined; await expect .poll(async () => { snap = await getState(page); const p = snap.planes.find((x) => x.id === 'plane-starter'); if (p && p.status === 'available' && p.locationIcao === nearest.icao) arrived = snap; return !!arrived; }, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` }) .toBe(true); // 4) Exact reward + reputation gain; plane grounded with the analytic remaining fuel. const after = arrived!; expect(after.account.cash).toBeCloseTo(before.account.cash + contract.reward, 6); const repGain = Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; // mirrors reputationGainFor expect(after.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 6); plane = after.planes.find((p) => p.id === 'plane-starter')!; expect(plane.status).toBe('available'); expect(plane.locationIcao).toBe(nearest.icao); expect(plane.departureSimTime).toBeNull(); // flight context cleared on landing expect(plane.fuel).toBeCloseTo(Math.max(0, departureFuel - BURN_PER_KM * km), 3); const done = after.contracts.find((c) => c.id === contract.id)!; expect(done.status).toBe('completed'); expect(done.planeId).toBe('plane-starter'); // 5) Refuel at the destination: cost is exactly (maxFuel − fuel) × FUEL_PRICE_PER_L. const cashBeforeRefuel = after.account.cash; const expectedCost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100; expect(expectedCost).toBeGreaterThan(0); // the flight genuinely burned fuel const refuel = await page.request.post('/api/planes/plane-starter/refuel'); expect(refuel.status()).toBe(200); const afterRefuel = (await refuel.json()) as StateBody; expect(afterRefuel.account.cash).toBeCloseTo(cashBeforeRefuel - expectedCost, 6); expect(afterRefuel.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(plane.maxFuel); // 6) Fly home so the live instance stays in its onboarding state for the next run. await flyContract(page, nearest.icao, HUB); }); test('dispatch without accept is rejected with 409; unknown contract id with 404', async ({ page }) => { const body = await getState(page); const starter = body.planes.find((p) => p.id === 'plane-starter')!; // A contract originating where the plane actually sits — so ONLY the missing // accept is what blocks dispatch. const created = await page.request.post('/api/test/create-contract', { data: { originIcao: starter.locationIcao, destIcao: HUB === starter.locationIcao ? 'LEMD' : HUB }, }); expect(created.status()).toBe(201); const contract = (await created.json()) as ContractState; const noAccept = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(noAccept.status()).toBe(409); expect(((await noAccept.json()) as { error: string }).error).toMatch(/open/); const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } }); expect(unknown.status()).toBe(404); });