import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows // loaded packages with fly-to-destination + abandon buttons (penalty preview from the same // formula as the server); flying planes show their current leg; market buy/sell/refuel previews // match the catalog + economy constants exposed in /api/state. Packages are created via // POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts interface PlaneState { id: string; model: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null; } interface PackageState { id: string; originName: string; destName: string; reward: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number; deadlineSimTime: number; } interface CatalogEntry { model: string; label: string; price: number; maxFuel: number; burnPerKm: number; emptyWeightKg: number; // v0.16.0 weighted burn input speedKmh: number; capacity: number; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; packages: PackageState[]; catalog: CatalogEntry[]; fuelPricePerL: number; sellFraction: number; } // Mirrors formatCash in lib/format.ts (same Intl settings → identical strings). const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); 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 }); } // Set the time scale and VERIFY from the response that it stuck — a silent reset would // otherwise surface as an unexplained landing-wait timeout. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/sim', { 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/sim`).toBe(scale); } // 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); }); // 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))); } // 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; } // Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000. async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> { 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; } } return nearest; } // Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0 // refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is // ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409; // this test wants a long-but-flyable leg instead. async function farthestFlyableToHub( page: Page, entry: CatalogEntry, ): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { 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 farthest: { icao: string; lat: number; lon: number; d: number } | null = null; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range if (!farthest || d > farthest.d) { farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; } } if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset'); return { ...farthest, hubLat: hub.lat, hubLon: hub.lon }; } // Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty — // closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm. const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number { const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); } // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise { const created = await page.request.post('/api/test/create-package', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs }, }); expect(created.status()).toBe(201); return (await created.json()) as PackageState; } // Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. async function flyHomeViaApi(page: Page): Promise { const s0 = await getState(page); const starter = s0.planes.find((p) => p.id === 'plane-starter')!; if (starter.status === 'flying' || starter.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (starter.status === 'flying') { await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 240_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); } if (starter.locationIcao !== HUB) { // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { timeout: 240_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); } } test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // deterministic: the starter sits at EDDF await page.goto('/'); const right = page.getByTestId('right-pane'); await expect(right).toBeVisible({ timeout: 15_000 }); // ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button. const rows = page.locator('li[data-testid^="open-package-"]'); await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toContainText(/→/); await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€'); // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot). const nearest = await nearestToHub(page); const pkg = await createPackage(page, HUB, nearest.icao); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); const loadBtn = page.getByTestId(`load-${pkg.id}`); await expect(loadBtn).toBeEnabled(); // Load through the UI — exactly one eligible plane, so the button acts directly. await loadBtn.click(); const body = await getState(page); expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded'); expect(body.packages.find((p) => p.id === pkg.id)?.planeId).toBe('plane-starter'); // The loaded package leaves the open list and shows in the on-board section. await expect(row).not.toBeVisible(); await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 }); }); test('Load is disabled when no plane sits at the package origin', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here const pkg = await createPackage(page, 'LEMD', HUB); await page.goto('/'); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); await expect(row).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled(); }); test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => { test.setTimeout(240_000); // one short flight at scale 1000 (seconds of real time) plus UI steps await flyHomeViaApi(page); const nearest = await nearestToHub(page); const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000); const loadRes = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(loadRes.status()).toBe(200); await page.goto('/'); const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 }); // The abandon button previews the exact penalty at the snapshot's clock (same formula as the server). // Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value, // making the preview deterministic instead of racing the 1 s poll loop. await postSim(page, { paused: true }); const state = await getState(page); const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs); await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty)); // Fly to the destination through the UI — the button is the destination's name. const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); await expect(flyBtn).toBeEnabled(); await flyBtn.click(); // The on-board block now shows the plane in flight with its current leg… await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 }); const leg = page.getByTestId('plane-leg-plane-starter'); await expect(leg).toBeVisible(); await expect(leg).toContainText('→'); // …and /api/state agrees. let body = await getState(page); expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying'); expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao); // Resume and fast-forward to arrival (poll the authoritative state). await postSim(page, { paused: false }); await setScale(page, SCALE); await expect .poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === nearest.icao; }, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` }) .toBe(true); // The in-deadline package was delivered on arrival — it leaves the on-board list… body = await getState(page); expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered'); await expect(onboardRow).not.toBeVisible({ timeout: 15_000 }); // Restore: fly home so the live instance stays in its onboarding state for the next run. // ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. await setScale(page, SCALE); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(homeRes.status()).toBe(200); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); }); test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight) const body = await getState(page); // authoritative catalog + economy constants (v0.10.0) expect(body.catalog.length).toBeGreaterThan(0); const entry = body.catalog.find((e) => e.model === 'atr72')!; expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot await page.goto('/'); // Buy: the model picker lists the catalog entry and the price preview is its full price. const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel).toBeVisible({ timeout: 15_000 }); await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label); await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price)); await expect(page.getByTestId('market-buy')).toBeVisible(); // Sell: the grounded-plane picker lists plane-starter and the payout preview is // sellFraction × price, both from the snapshot's catalog/constants. const starter = body.planes.find((p) => p.id === 'plane-starter')!; expect(starter.status).toBe('available'); await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 }); const payout = Math.round(entry.price * body.sellFraction * 100) / 100; await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout)); // Refuel through the pane: the flight genuinely burned fuel… if (starter.fuel < starter.maxFuel) { const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100; const cashBefore = body.account.cash; // …the refuel picker lists it, the preview matches the exact cost, and the click deducts it. await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('market-refuel-plane').selectOption('plane-starter'); await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost)); await page.getByTestId('market-refuel').click(); // Cash decreased by exactly the expected cost and the tank is full (server truth). await expect .poll(async () => { const b = await getState(page); return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001; }, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' }) .toBe(true); const after = await getState(page); expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel); } }); test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => { test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 await flyHomeViaApi(page); const body = await getState(page); const entry = body.catalog.find((e) => e.model === 'atr72')!; // Deterministic baseline: a full tank. expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); // Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it // exceeds what's left on arrival. const farthest = await farthestFlyableToHub(page, entry); const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); // Fly there (delivers pkg1 on arrival) — the tank is now partial. expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200); await setScale(page, SCALE); await expect .poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === farthest.icao; }, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` }) .toBe(true); // New work at the far end: a package back to the hub — its leg is exactly what we refuel for. const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000); expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); // Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts). const pre = await getState(page); const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel; const cashBefore = pre.account.cash; const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore); expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; await page.goto('/'); const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`); await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 }); // Capture the endpoint's OWN response — the full post-action snapshot at the action's clock. const [res] = await Promise.all([ page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }), refuelBtn.click(), ]); expect(res.status()).toBe(200); const post = (await res.json()) as StateBody; // Cash dropped by exactly L × price and fuel rose by exactly L. expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6); // Market row: the fill-up button is labeled "Refuel to full" (testid unchanged). await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full'); // Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds // exactly the leg requirement, so this also proves "just enough" is enough. ISS-011: the far-end // arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. await setScale(page, SCALE); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { timeout: 240_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); // Top the tank back up so later specs start with a full tank. expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); });