From 008a77e456deee2d9912bced0cc478304f66429c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 16:03:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.18.0=20part=203=20=E2=80=94=20market?= =?UTF-8?q?=20row=20to=20relocate=20a=20grounded=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 ++ app/globals.css | 6 ++ components/RightPane.tsx | 76 +++++++++++++++- e2e/relocation.spec.ts | 183 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 5 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 e2e/relocation.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 111e2cd..8f4632a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.18.0 — 2026-09-19 16:02 +- **Relocate a grounded (empty) plane to any airport (ISS-010).** `flyPlane` has allowed pure repositioning since v0.12.0, but the UI only exposed flying for planes that carried cargo — an empty grounded plane had no fly control anywhere. Two new surfaces now drive the EXISTING `POST /api/planes/{id}/fly {destIcao}` endpoint (no API or OpenAPI change): + - **Right-pane market row** (`components/RightPane.tsx`): a fourth `.market-row` with `[data-testid="relocate-plane"]` (grounded planes, "ATR 72-600 — EDDF" labels), `[data-testid="relocate-dest"]` (all airports from a one-time `GET /api/airports` fetch per page life — same promise-ref pattern as Map.tsx — sorted by name for stable e2e selection) and the `[data-testid="relocate-go"]` "Relocate" button, disabled while busy or until both selects have a value. Same effective-selection + `runAction` pattern as the buy/sell/refuel rows (first grounded plane / first airport preselected); the row wraps (`market-row-relocate`) since two selects don't fit one 360 px line + - **Airport popup Relocate row** (`components/Map.tsx`): after the name/ICAO/local-time/weather lines, a `.popup-relocate` block with `[data-testid="airport-popup-plane"]` (grounded planes from the latest polled snapshot at click time) and `[data-testid="airport-popup-relocate"]` "Relocate here" — disabled until a plane is picked; on click it POSTs fly to the clicked airport, pushes the response snapshot through the shell's `applySnapshot` (new Map prop, same callback RightPane receives), and reports success/failure in `[data-testid="airport-popup-relocate-status"]`. With no grounded planes the row shows `[data-testid="airport-popup-no-planes"]` instead. Popups are plain HTML, not React — controls wired via `popup.getElement().querySelector(...)` after `.addTo(map)` +- **Bigger airport markers (ISS-010).** The r=3 dots were hard to click: `airports-circles` paint in `components/Map.tsx` goes from radius 3 / stroke 1 to radius 5 / stroke 1.5, and the hover highlight ring follows (r=8 → r=9). Paint-only — no data or interaction changes +- No server or OpenAPI changes — both surfaces reuse the existing fly endpoint (validations unchanged: 404 unknown plane / 409 flying or insufficient fuel / 400 unknown ICAO, strict unknown-field rejection kept) +- Tests: e2e — new `e2e/relocation.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts `/api/state` shows `flying` with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing +- Verified: `tsc --noEmit` clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack + ## v0.17.0 — 2026-09-19 14:49 - **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css` - **Event messages via snapshot diffing.** No server event feed exists, so new pure `lib/deriveEvents.ts` — `diffSnapshots(prev: StateSnapshot | null, next) → {kind:'info', message}[]` — derives human-readable events from the fields already in `/api/state`: plane landing (`flying→available`, "ATR 72-600 landed at Frankfurt"), package delivery (`loaded→delivered`, "Package delivered: +€X" with the reward), expiry (`open|loaded→expired`, "Package expired: A → B"), new packages (id only in next, "New package available: A → B") and planes bought/sold (plane id set changed, "Bought/Sold {model}"). The shell keeps a `prevRef` and diffs on every snapshot change, pushing each event as an info toast; the first snapshot after load emits nothing (no baseline — no page-load spam) diff --git a/app/globals.css b/app/globals.css index d9dba6f..657085f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -361,6 +361,12 @@ body { padding: 4px 0; } +/* ISS-010 (v0.18.0) — the Relocate row carries TWO selects, so let it wrap instead of + squeezing both below their readable width in the 360 px pane. */ +.market-row-relocate { + flex-wrap: wrap; +} + .market-preview { font-size: 12px; color: #f8fafc; diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 216992b..91c9aeb 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; @@ -122,6 +123,32 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr const [sellPlaneId, setSellPlaneId] = useState(null); const [refuelPlaneId, setRefuelPlaneId] = useState(null); + // ISS-010 (v0.18.0): Relocate row selections — same "effective selection" pattern as the rows above: + // the user's pick while it stays valid, else a sensible default (first grounded plane / first airport). + const [relocatePlaneId, setRelocatePlaneId] = useState(null); + const [relocateDestIcao, setRelocateDestIcao] = useState(null); + + // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the + // Relocate row, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx; + // on failure airportOptions stays null and the Relocate button simply remains disabled. + const [airportOptions, setAirportOptions] = useState | null>(null); + const airportsPromiseRef = useRef> | null>(null); + useEffect(() => { + if (!airportsPromiseRef.current) { + airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' }) + .then((res) => { + if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((fc) => + fc.features + .map((f) => ({ name: f.properties.name, icao: f.properties.icao })) + .sort((a, b) => a.name.localeCompare(b.name, 'en')), + ); + } + void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); + }, []); + // POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0 // convention) — pushed into the shared state immediately; a 4xx {error} body becomes the // inline pane error and the stale UI stays until the next poll. Buttons are disabled while @@ -182,6 +209,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ?? refuelable[0] ?? null; + // ISS-010 (v0.18.0): Relocate — any GROUNDED plane can be repositioned to any airport via the + // existing fly endpoint (pure repositioning, no package needed since v0.12.0). + const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null; + const relocateDest = + airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null; // Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0). const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined; @@ -422,6 +454,48 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr Refuel to full + + {/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure + repositioning, this row just exposes it for planes with nothing on board. */} +
+ + + +
{actionError &&

{actionError}

} diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts new file mode 100644 index 0000000..17414f4 --- /dev/null +++ b/e2e/relocation.spec.ts @@ -0,0 +1,183 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. +// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has +// been allowed since v0.12.0 — no API change): the right-pane market row +// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on +// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by +// the existing e2e/map.spec.ts click tests, not here. + +const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time +const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) +// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px +// away at the initial zoom, so a click on its projected center cannot hit another circle. +const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; + +interface StateBody { + planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>; +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.ok()).toBeTruthy(); + return (await res.json()) as StateBody; +} + +async function postSim(page: Page, body: Record): Promise { + const res = await page.request.post('/api/sim', { data: body }); + expect(res.ok()).toBeTruthy(); +} + +// Set the time scale and VERIFY from the response that it stuck — if something external ever +// resets the live instance's controls mid-test, fail loudly here instead of timing out later. +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); +} + +// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial). +async function nearestToHub(page: Page): Promise<{ icao: string }> { + const res = await page.request.get('/api/airports'); + expect(res.ok()).toBeTruthy(); + const fc = (await res.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; + const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon); + const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon); + if (d < dn) nearest = f.properties; + } + return { icao: nearest.icao }; +} + +// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and +// reposition plane-starter home via the API — repositioning needs no package (v0.12.0 manual +// flight) — so every test starts from the canonical state even after an interrupted previous run. +async function flyHomeViaApi(page: Page): Promise { + let s = await getState(page); + let plane = s.planes.find((p) => p.id === 'plane-starter')!; + 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 + // before deciding whether a repositioning flight is needed. + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); + s = await getState(page); + plane = s.planes.find((p) => p.id === 'plane-starter')!; + } + if (plane.locationIcao !== HUB) { + const res = await page.request.post(`/api/planes/${plane.id}/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 === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); + } +} + +// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so +// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs. +test.afterEach(async ({ page }) => { + await flyHomeViaApi(page); + await postSim(page, { timeScale: 1, paused: false }); +}); + +test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file + +test('market row relocates a grounded plane via the right pane', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg + await flyHomeViaApi(page); + + // Full tank before departure — a partially drained plane left by an earlier spec could not + // afford the cleanup round trip. + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.ok()).toBeTruthy(); + + const nearest = await nearestToHub(page); + + await page.goto('/'); + const planeSel = page.getByTestId('relocate-plane'); + await expect(planeSel).toBeVisible({ timeout: 15_000 }); // pane renders once the first snapshot lands + await planeSel.selectOption('plane-starter'); + + // Destination options come from the one-time /api/airports fetch (sorted by name) — selectOption + // waits until the matching option exists, so this doubles as the load wait. + const destSel = page.getByTestId('relocate-dest'); + await destSel.selectOption(nearest.icao); + + const goBtn = page.getByTestId('relocate-go'); + await expect(goBtn).toBeEnabled(); + await goBtn.click(); + + // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), + // so poll until it took effect on the server. + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter')!; + return p.status === 'flying' && p.destIcao === nearest.icao; + }, { timeout: 15_000, message: 'waiting for the relocate click to take effect (status "flying" + dest set)' }).toBe(true); +}); + +test('airport popup offers Relocate options for grounded planes', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS + await flyHomeViaApi(page); + + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.ok()).toBeTruthy(); + + await page.goto('/'); + + // Wait until the airport count renders (airports loaded) and the map has a snapshot containing + // the plane — the popup's Relocate row is built from that same latest snapshot. + const countEl = page.getByTestId('airport-count'); + await expect.poll(async () => { + const text = (await countEl.textContent()) ?? ''; + const m = text.match(/(\d+)/); + return m ? parseInt(m[1], 10) : -1; + }, { timeout: 15_000 }).toBeGreaterThan(0); + await expect.poll(async () => + page.evaluate(() => { + const m = (window as any).__flightMap; + if (!m || !m.getSource('planes')) return false; + try { + return m.querySourceFeatures('planes').some((f: { properties?: Record }) => f.properties?.id === 'plane-starter'); + } catch { + return false; // source not ready yet — keep polling + } + }), + { timeout: 15_000 }, + ).toBe(true); + + // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). + const pt = await page.evaluate(() => { + const map = (window as any).__flightMap; + const p = map.project([POPUP_AIRPORT.lon, POPUP_AIRPORT.lat]); + const rect = map.getContainer().getBoundingClientRect(); + return { x: rect.left + p.x, y: rect.top + p.y }; + }); + await page.mouse.click(pt.x, pt.y); + + const popup = page.getByTestId('airport-popup'); + await expect(popup).toBeVisible(); + await expect(popup).toContainText(POPUP_AIRPORT.icao); + + // The Relocate row: a select of grounded planes + the button (disabled until one is picked). + const planeSel = page.getByTestId('airport-popup-plane'); + await expect(planeSel).toBeVisible(); + await expect(planeSel.locator('option')).toContainText(/ATR 72-600 — EDDF/); + const goBtn = page.getByTestId('airport-popup-relocate'); + await expect(goBtn).toBeDisabled(); // placeholder selected + + await planeSel.selectOption('plane-starter'); + await expect(goBtn).toBeEnabled(); + await goBtn.click(); + + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter')!; + return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao; + }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true); +}); diff --git a/package.json b/package.json index b286f75..adc239c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.17.0", + "version": "0.18.0", "private": true, "scripts": { "dev": "next dev -p 3000",