import type { Page, Request } from '@playwright/test'; import { expect, test } from '@playwright/test'; // Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves // the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. import { initialBearingDeg } from '../lib/geo'; import { weatherFor } from '../lib/weather'; import { windBurnFactor } from '../lib/sim/movement'; // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // the full core loop through the UI — hook creates a short package at EDDF → Load // via the right pane → click its destination in the on-board list (left pane since // v0.20.0, ISS-021) → fly at 1000× → // arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula // penalty while grounded; expired packages deliver/abandon with no reward and no // penalty. Mirrors lib/game/penalty.ts for exact-value assertions. const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) // Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn). const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts const REP_BASE = 1; // lib/sim/engine.ts const REP_PER_KM = 0.0005; // Fuel remaining after distanceKm from startFuel — closed form of dF/dx = −k(We + ρF), // k calibrated so a full tank burns exactly burnPerKm (mirrors fuelAtWeighted). function fuelAfter(startFuel: number, burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number { const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); const rho = FUEL_DENSITY_KG_PER_L; return Math.max(0, (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * distanceKm) - emptyWeightKg / rho); } // Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock. function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number { const span = deadlineSimTime - createdSimTime; const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1; return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100; } 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; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>; packages: Array<{ id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number; deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number; }>; catalog: Array<{ model: string; burnPerKm: number; emptyWeightKg: number; maxFuel: number }>; // v0.16.0 weighted burn inputs } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); expect(res.ok()).toBeTruthy(); return (await res.json()) as StateBody; } // The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. // weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load // several packages onto one plane stay under its payload deterministically. async function createPackage( page: Page, from?: string, to?: string, deadlineOffsetMs?: number, weightKg?: number, ): Promise { const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } }); expect(res.status()).toBe(201); return (await res.json()) as StateBody['packages'][number]; } // Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel // endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed. async function setFuelFull(page: Page, planeId: string): Promise { const s = await getState(page); const p = s.planes.find((x) => x.id === planeId)!; expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate 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. 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): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts. 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 why the expiry test captures its // cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards. async function activateWorld(page: Page): Promise { await setHealth(page, 'plane-starter', { health: 50 }); expect((await page.request.post('/api/planes/plane-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, 'plane-starter', { health: 100, repairUntilMs: 0 }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 30_000, message: 'waiting for the starter to flip back to available', }) .toBe('available'); } // Closest airport to the hub from /api/airports → shortest possible flight. Returns both // endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts. async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { 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, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon }; } // Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so // a failure mid-test can't leave the live instance fast-forwarded for later specs. test.afterEach(async ({ page }) => { await setScale(page, WORLD); }); test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file // Self-heal helper: let any in-flight leg finish, reposition plane-starter home via the API // (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so // exact cash/rep assertions start from a clean baseline even after an interrupted previous run. async function flyHomeViaApi(page: Page): Promise { // v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any // active repair gate from an interrupted run before the steps below act on the starter, so the // exact-fuel pins never see a worn or broken plane (broken planes burn ×1.5 more). expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); let s = await getState(page); // ticks — a shortened gate flips the plane back to available here 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, so the pre-landing snapshot can't tell us where the plane ends up — re-read // state after landing before deciding whether a repositioning flight is needed (run #41 retry: a // leftover EDDF→EDFH leg landed away from home but the helper still believed it was home and // skipped the fly-home). 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) { // Make sure the world rate is back at SCALE so the return leg fast-forwards. await setScale(page, SCALE); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); expect(res.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); } // Abandon leftover loaded/expired packages (expired ones cost nothing; loaded ones take a // penalty — irrelevant, the baseline snapshot is captured AFTER this cleanup). const s1 = await getState(page); for (const pkg of s1.packages) { if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) { const res = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(res.ok()).toBeTruthy(); } } } test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights // Self-heal: the starter plane may be anywhere from a previous run/spec. await flyHomeViaApi(page); const before = await getState(page); expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB); // Nearest airport to the hub → shortest possible flight (fastest e2e loop). const nearest = await nearestToHub(page); // A known short package at the hub with a generous deadline. const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000); expect(pkg.originIcao).toBe(HUB); expect(pkg.destIcao).toBe(nearest.icao); // Precondition (server truth) before clicking Load: the starter must be grounded at the package's // origin — that is exactly what makes it load-eligible, and a stale pane snapshot can't prove it. await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'available' && p.locationIcao === pkg.originIcao; }, { timeout: 30_000, message: 'waiting for plane-starter to be grounded at the package origin (self-heal incomplete?)' }).toBe(true); // LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button. await page.goto('/'); const loadBtn = page.getByTestId(`load-${pkg.id}`); await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled(); await loadBtn.click(); // The action endpoint's full snapshot is applied immediately (applySnapshot) — the // package shows as loaded on the starter in the on-board section. const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible(); // ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names. await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`); // v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km. await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/); // FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label). const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); await expect(flyBtn).toBeEnabled(); await flyBtn.click(); const planeId = 'plane-starter'; // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), // so a getState issued immediately can race ahead of it and still see 'available' — poll until the // fly actually took effect on the server (the run #36/#41 flake). await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === planeId)!; return p.status === 'flying' && p.destIcao === nearest.icao; }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); let s = await getState(page); // Fast-forward to arrival (a few seconds of real time at 1000×). await setScale(page, SCALE); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.status), { timeout: 120_000 }).toBe('available'); s = await getState(page); const landed = s.planes.find((p) => p.id === planeId)!; expect(landed.locationIcao).toBe(nearest.icao); // arrived at the package's destination expect(landed.destIcao).toBeNull(); // flight context cleared on landing // EXACT reward: cash increased by exactly the package's payout… const delivered = s.packages.find((p) => p.id === pkg.id)!; expect(delivered.status).toBe('delivered'); expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward, 6); // …and rep gained per the engine formula (great-circle km of the leg just flown), // mirroring lib/sim/engine.ts's double rounding (gain rounded to cents, then total). const exactKm = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon); const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100; expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9); // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift), // wind-adjusted (v0.36.0) by departure weather vs route heading. const entry = before.catalog.find((e) => e.model === 'atr72')!; const wHub = weatherFor(HUB); expect(landed.fuel).toBeCloseTo( fuelAfter( before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, exactKm, ), 6, ); // ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the // exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here. // Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE. await setScale(page, SCALE); const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); expect(homeRes.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB); }); test('abandon while grounded deducts exactly the formula penalty', async ({ page }) => { test.setTimeout(120_000); await flyHomeViaApi(page); const before = await getState(page); // Two packages at the hub — load both, abandon one through the UI. Forced weights keep // the pair under the starter's payload (v0.27.0) so both loads are deterministic. const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000); const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000); for (const p of [pkgA, pkgB]) { const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); expect(res.ok()).toBeTruthy(); } // Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked // against the exact game clock the server used (a later GET would tick the clock further). await page.goto('/'); const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`); await expect(abandonBtn).toBeEnabled(); const [res] = await Promise.all([ page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }), abandonBtn.click(), ]); expect(res.status()).toBe(200); const s = (await res.json()) as StateBody; // full post-action snapshot at the penalty's clock const expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs); expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor const abandoned = s.packages.find((p) => p.id === pkgA.id)!; expect(abandoned.status).toBe('abandoned'); expect(abandoned.planeId).toBeNull(); // slot freed expect(s.account.cash).toBeCloseTo(before.account.cash - expectedPenalty, 6); // B is still on board — abandon it via the API to leave a clean state. const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`); expect(resB.ok()).toBeTruthy(); }); test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait await flyHomeViaApi(page); // A: load, let it expire while loaded, then FLY to its destination — arrival must // unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's // destination differs from A's so it stays bound on the plane at LEMD arrival and can // still be abandoned (an expired package whose dest matches the arrival airport is // unbound by the tick instead). const pkgA = await createPackage(page, HUB, 'LEMD', 10_000, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0) const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000); for (const p of [pkgA, pkgB]) { const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); expect(res.ok()).toBeTruthy(); } // Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs — // put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep // baseline below is captured AFTER deactivateWorld, not before it. await activateWorld(page); try { // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). await setScale(page, SCALE); let s = await getState(page); await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired'); s = await getState(page); expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired'); } finally { await deactivateWorld(page); } const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this let s = before; // Fly to the destination — arrival unbinds A for free (no reward, no rep). const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } }); expect(flyRes.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); s = await getState(page); const landedA = s.packages.find((p) => p.id === pkgA.id)!; expect(landedA.status).toBe('expired'); // still expired — never delivered… expect(landedA.planeId).toBeNull(); // …but the slot was freed on arrival expect(s.account.cash).toBe(before.account.cash); // NO reward for expired cargo expect(s.account.reputation).toBe(before.account.reputation); // no rep either // B: abandon an expired package while grounded — penalty is exactly €0. const cashBeforeAbandon = s.account.cash; const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`); expect(resB.ok()).toBeTruthy(); s = await getState(page); expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing // Restore: top up + fly home at SCALE. await setFuelFull(page, 'plane-starter'); await setScale(page, SCALE); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(homeRes.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); }); test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { test.setTimeout(300_000); // self-heal + one short hop each way at scale 1000 await flyHomeViaApi(page); // Load a package whose origin the plane is not at → 409. const pkg = await createPackage(page, 'LEMD', HUB, 24 * 3_600_000); // originates in Madrid, plane sits in Frankfurt let res = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(res.status()).toBe(409); expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/); // Load an unknown package → 404. res = await page.request.post('/api/packages/no-such-package/load', { data: { planeId: 'plane-starter' } }); expect(res.status()).toBe(404); // Fly to an ICAO not in the dataset → 400. res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'ZZZZ' } }); expect(res.status()).toBe(400); expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/); // Fly while already flying → 409 (short hop leg on the nearest route, then let it finish). const hopDest = await nearestToHub(page); res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: hopDest.icao } }); expect(res.ok()).toBeTruthy(); const again = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(again.status()).toBe(409); await setScale(page, SCALE); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(hopDest.icao); // Abandon an open package → 409 (nothing loaded to abandon). res = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(res.status()).toBe(409); // Fly home from the hop so the fuel section starts at the hub, at SCALE. await setScale(page, SCALE); res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(res.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB); // ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch // auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining // insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and // app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here. }); // Great-circle distance in km — mirrors lib/geo.ts (same formula, same R). function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; const R = 6371; 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 * R * Math.asin(Math.min(1, Math.sqrt(a))); }