From 7f28666f2ea24e1fa45ed6bb0c469ade1e4a94ed Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 27 Sep 2026 22:56:16 +0000 Subject: [PATCH] v1.11.0: hide open-package rows with no grounded plane at the origin (ISS-159) --- CHANGELOG.md | 4 ++++ components/RightPane.tsx | 11 +++++++++- components/right-pane.test.ts | 38 ++++++++++++++++++++++++++++++++++- e2e/right-pane.spec.ts | 20 ++++++++++++++++++ package.json | 2 +- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cafd0b..0bcaae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v1.11.0 — 2026-09-27 22:55 +- **Do not show jobs for planes that are in air (ISS-159).** The right pane's open-package list now hides a row while no GROUNDED plane sits at the package's origin (`components/RightPane.tsx`): new exported `visibleOpenPackages(state)` replaces the bare `status === 'open'` filter — a job whose only claimant is still en route disappears from the list and reappears on landing. Payload-blocked rows (a grounded plane at the origin with no room) stay visible with their disabled Load button, exactly as before. Display-only: `poolAirports` and the spawner are untouched, so world state — jobs waiting at a flying plane's destination — is unchanged; this only stops rendering rows the player cannot dispatch yet. Known pre-existing gap (NOT fixed here): `loadEligiblePlanes` ignores plane ownership per the v1.4.0 shared-market design, so another player's grounded plane at an origin can make Load enabled for you → 403 on click. +- **Tests.** Unit: new `visibleOpenPackages` suite in `components/right-pane.test.ts` — grounded plane at origin → visible; only a flying plane there → hidden; grounded elsewhere + one flying to the origin → hidden; payload-blocked grounded plane at origin → still visible with Load disabled. E2E: new test in `e2e/right-pane.spec.ts` dispatches the only plane toward an airport, creates a job AT that destination (in the pool while airborne, so it reaches `/api/state`), and asserts no row renders until landing — pinning the client-side filter rather than the v0.65.0 server-side pool visibility. + ## v1.10.0 — 2026-09-27 22:31 - **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn). - **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button. diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 7da33db..3e4e766 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -46,6 +46,15 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla }); } +// v1.11.0 (ISS-159): the open packages the market list shows — hidden while no GROUNDED plane sits at +// their origin ("jobs for planes that are in air" disappear until landing). Payload-blocked rows stay: +// a grounded plane at the origin keeps the row visible with its disabled Load button. +export function visibleOpenPackages(state: StateSnapshot): PackageState[] { + return state.packages.filter( + (p) => p.status === 'open' && state.planes.some((pl) => pl.status === 'available' && pl.locationIcao === p.originIcao), + ); +} + // Load controls for one open package: a single Load button when exactly one plane // is eligible (it acts on that plane directly), a small plane picker + Load when // several are, and a disabled button with the reason otherwise. Each row owns its @@ -141,7 +150,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr if (!state) return null; const clockMs = state.simState.clockMs; - const open = state.packages.filter((p) => p.status === 'open'); + const open = visibleOpenPackages(state); const catalog = state.catalog; const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null; diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index 59ca889..5e8d8e1 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { cargoWeightFor, loadEligiblePlanes } from './RightPane'; +import { cargoWeightFor, loadEligiblePlanes, visibleOpenPackages } from './RightPane'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; // v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a @@ -134,3 +134,39 @@ describe('loadEligiblePlanes (Load button eligibility)', () => { expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); }); }); + +describe('visibleOpenPackages (market list row filter, ISS-159)', () => { + it('shows an open package when a grounded plane sits at its origin', () => { + const s = snap( + [plane({ id: 'at-origin' })], + [pkg({ id: 'x' }), pkg({ id: 'onboard', status: 'loaded', planeId: 'at-origin' })], // non-open statuses never listed + ); + expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']); + }); + + it('hides an open package when only a flying plane is at its origin (in air, not grounded)', () => { + const s = snap([plane({ id: 'en-route', status: 'flying', destIcao: 'LEMD' })], [pkg({ id: 'x' })]); + expect(visibleOpenPackages(s)).toEqual([]); + }); + + it('hides an open package when the grounded plane is elsewhere and another flies to its origin', () => { + const s = snap( + [ + plane({ id: 'grounded-elsewhere', locationIcao: 'LEMD' }), + plane({ id: 'en-route', status: 'flying', destIcao: 'EDDF' }), // flying TO the package's origin — not grounded there yet + ], + [pkg({ id: 'x' })], + ); + expect(visibleOpenPackages(s)).toEqual([]); + }); + + it('keeps a payload-blocked row visible when a grounded plane at the origin has no room (disabled Load path preserved)', () => { + const packages = [ + ...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 })), // 4 × 1200 = 4800 — full + pkg({ id: 'x' }), + ]; + const s = snap([plane({ id: 'full' })], packages); + expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']); // row stays… + expect(loadEligiblePlanes(s, s.packages.find((p) => p.id === 'x')!)).toEqual([]); // …with Load disabled + }); +}); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index d8b2dba..4c3093d 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -270,6 +270,26 @@ test('open jobs only appear where a plane can take them — foreign-origin jobs await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 }); }); +test('jobs at an airport with no grounded plane stay hidden while a plane is still en route (ISS-159)', async ({ page }) => { + test.setTimeout(300_000); // self-heal can take minutes after an interrupted run + await flyHomeViaApi(page); // starter grounded at EDDF + + const nearest = await nearestToHub(page); + // Dispatch the only plane toward `nearest` — while airborne its DESTINATION joins the pool (v0.65.0), so a job there + // reaches /api/state, but no GROUNDED plane sits at that airport yet: ISS-159 hides it until landing. + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } }); + expect(flyRes.status()).toBe(200); + + const pkg = await createPackage(page, nearest.icao, HUB); + // The server keeps the job (its origin is in the pool)… + await expect.poll(async () => (await getState(page)).packages.some((p) => p.id === pkg.id), { timeout: 15_000 }).toBe(true); + + await page.goto('/'); + // …but no row renders while the plane is still en route — no grounded plane at its origin. No landing wait needed; + // the next test's flyHomeViaApi self-heal finishes this leg. + await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 }); +}); + 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); diff --git a/package.json b/package.json index ecbae6b..175e199 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.10.0", + "version": "1.11.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0