diff --git a/CHANGELOG.md b/CHANGELOG.md
index edd1539..6f62eba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset.
- **E2E hardening (CI run #167).** Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon `` (`/icon.svg?icon..svg` — matched with `/^\/icon\.svg/`, canonical `GET /icon.svg` check unchanged), and `e2e/hover.spec.ts`'s local `flyHomeViaApi` self-heal is replaced by the established `e2e/panes.spec.ts` pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked `locationIcao` against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin").
- **E2E hardening (CI run #168).** Retry-cascade fix in `e2e/actions.spec.ts`: its local `flyHomeViaApi` self-heal now also tops plane-starter's tank back to full via the free `POST /api/test/set-fuel` hook after repositioning home. A transient `/api/state` 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0.
+- **E2E hardening (ISS-127).** e2e `getState` hardened against transient `/api/state` 500s — internal retry ×3/500 ms keeps poll callbacks total.
## v0.54.0 — 2026-09-22 15:20
- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts
index c1e6410..0f1b7e2 100644
--- a/e2e/actions.spec.ts
+++ b/e2e/actions.spec.ts
@@ -50,9 +50,14 @@ interface StateBody {
}
async function getState(page: Page): Promise {
- const res = await page.request.get('/api/state');
- expect(res.ok()).toBeTruthy();
- return (await res.json()) as StateBody;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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);
+ }
}
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts
index bbfb94a..a22c8c9 100644
--- a/e2e/hover.spec.ts
+++ b/e2e/hover.spec.ts
@@ -48,9 +48,14 @@ interface StateBody {
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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 —
diff --git a/e2e/maintenance.spec.ts b/e2e/maintenance.spec.ts
index 0e1c102..c16bc03 100644
--- a/e2e/maintenance.spec.ts
+++ b/e2e/maintenance.spec.ts
@@ -42,9 +42,14 @@ interface StateBody {
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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 —
diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts
index ea5c388..3dfd525 100644
--- a/e2e/map-interactions.spec.ts
+++ b/e2e/map-interactions.spec.ts
@@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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 —
diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts
index ca21d26..e4b416b 100644
--- a/e2e/panes.spec.ts
+++ b/e2e/panes.spec.ts
@@ -16,9 +16,14 @@ interface StateBody {
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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.
diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts
index 8dd0bc5..5d59e15 100644
--- a/e2e/queue.spec.ts
+++ b/e2e/queue.spec.ts
@@ -47,9 +47,14 @@ interface StateBody {
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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.
diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts
index a776a1f..709f1e0 100644
--- a/e2e/relocation.spec.ts
+++ b/e2e/relocation.spec.ts
@@ -21,9 +21,14 @@ interface StateBody {
}
async function getState(page: Page): Promise {
- const res = await page.request.get('/api/state');
- expect(res.ok()).toBeTruthy();
- return (await res.json()) as StateBody;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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): top the starter's tank back to full — replaces the removed /refuel
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts
index 2ce26fc..d78ea56 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -70,9 +70,14 @@ interface StateBody {
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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 —
diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts
index 3e39b60..b0b89ca 100644
--- a/e2e/sim.spec.ts
+++ b/e2e/sim.spec.ts
@@ -23,9 +23,14 @@ interface StateBody {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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.
diff --git a/e2e/toasts.spec.ts b/e2e/toasts.spec.ts
index 3ffd46e..e7773e0 100644
--- a/e2e/toasts.spec.ts
+++ b/e2e/toasts.spec.ts
@@ -18,9 +18,14 @@ interface StateBody {
}
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts
index e617a63..11d1c0b 100644
--- a/e2e/two-planes.spec.ts
+++ b/e2e/two-planes.spec.ts
@@ -67,9 +67,14 @@ interface StateBody {
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;
+ // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
+ // instantly fail any expect.poll callback, so retry briefly instead.
+ 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.