diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 653153f..d4e10f8 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -87,6 +87,31 @@ async function setScale(page: Page, scale: number): Promise { 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 }> { @@ -296,7 +321,6 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page 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); - const before = await getState(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 @@ -310,12 +334,23 @@ test('expired packages deliver and abandon with no reward and no penalty', async expect(res.ok()).toBeTruthy(); } - // 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'); + // 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' } }); diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 21c99b0..ea5c388 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -258,15 +258,21 @@ test('plane markers move while flying and the plane popup shows status + route', // view) the per-second drift is sub-pixel, so the position is static for all practical purposes; // we restore SCALE right after the popup checks. await setScale(page, 1); + // At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time), + // so exact lat/lon equality between polls can never hold — "settled" means slow enough to click: + // two consecutive snapshots within EPS of each other. Per-tick advance is clamped to + // MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even + // for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock + // would show (~km per poll pair). let prevPos: { lat: number; lon: number } | null = null; await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); if (!p || p.status !== 'flying') return false; - if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true; + if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true; prevPos = { lat: p.lat, lon: p.lon }; return false; - }, { timeout: 15_000, message: 'waiting for the scale-1 clock to settle the plane position' }) + }, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' }) .toBe(true); // Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1 diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index 38de1f4..29680ec 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -4,6 +4,8 @@ import { test, expect, type Page } from '@playwright/test'; // The panes render from the shell's single /api/state poll (lib/useGameState.ts). const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts +const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only) +const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const STARTER = 'plane-starter'; interface StateBody { @@ -51,6 +53,34 @@ async function deactivateWorld(page: Page): Promise { .toBe('available'); } +// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and +// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight, +// and repairPlane 409s on non-available planes, which would break activateWorld. +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 acting on the starter. + expect((await page.request.post('/api/test/set-health', { data: { planeId: 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 === STARTER) ?? {}) as { status?: string; locationIcao?: string }; + 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') { + // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available'); + s = await getState(page); + plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string }; + } + 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/${STARTER}/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 === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB); + } +} + // Leave the live instance in a sane state for the other specs (the fixed world rate) — same // pattern as e2e/sim.spec.ts. test.afterEach(async ({ page }) => { @@ -111,7 +141,11 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt', }); test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => { - test.setTimeout(60_000); + test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling + // Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane + // 409s on non-available planes — it must be grounded before activateWorld. + await flyHomeViaApi(page); + await setScale(page, WORLD); // self-heal may have left the fast test rate behind // Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances. await activateWorld(page); try { diff --git a/lib/db/client.ts b/lib/db/client.ts index de92c2b..5369cd1 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -165,6 +165,7 @@ async function open(file: string): Promise { // First-run seed: only when the DB is empty (no account row yet). const res = raw.exec('SELECT COUNT(*) FROM account'); if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { + console.warn(`flight db: no existing world at ${file} — seeding a fresh one`); await seedEmpty(instance); }