diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b97f03..6f62eba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## v0.55.0 — 2026-09-22 15:38
+- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `` into the head; no layout/metadata changes needed.
+- **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).
- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..76e6ffe
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts
index d4e10f8..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.
@@ -176,6 +181,10 @@ async function flyHomeViaApi(page: Page): Promise {
expect(res.ok()).toBeTruthy();
}
}
+ // Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own
+ // dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so
+ // exact-cash assertions never see that cost.
+ await setFuelFull(page, 'plane-starter');
}
diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts
index 64c8d85..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 —
@@ -84,33 +89,30 @@ async function setFuelFull(page: Page, planeId: string): Promise {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
-// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
+// Self-heal (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an
+// interrupted run, 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')!;
+ // 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: '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 starter = s.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);
+ // 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 === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
+ s = await getState(page);
+ starter = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (starter.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-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);
+ await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
}
}
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 29680ec..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.
@@ -192,3 +197,16 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
});
+
+test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => {
+ await page.goto('/');
+ // App Router file convention: app/icon.svg is auto-served and injected into the head.
+ const link = page.locator('head link[rel~="icon"]');
+ await expect(link).toHaveCount(1);
+ // Production builds append a content-hash query string to the auto-injected icon href.
+ expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/);
+
+ const res = await page.request.get('/icon.svg');
+ expect(res.status()).toBe(200);
+ expect(res.headers()['content-type']).toContain('image/svg+xml');
+});
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.
diff --git a/package.json b/package.json
index 11fd2f1..40299ee 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.54.0",
+ "version": "0.55.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",