Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d215203988 | ||
|
|
11fc6f9d5c |
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## v0.49.0 — 2026-09-22 07:25
|
||||
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
|
||||
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
|
||||
|
||||
## v0.48.0 — 2026-09-22 06:55
|
||||
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
||||
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
||||
|
||||
+14
-9
@@ -231,21 +231,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||
const w = weatherFor(p.icao);
|
||||
const localTime = approxLocalTime(p.lon, Date.now());
|
||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
||||
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
|
||||
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
|
||||
const st = stateRef.current;
|
||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
||||
const planes = st ? st.planes : [];
|
||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||
const relocateHtml =
|
||||
groundedPlanes.length > 0
|
||||
planes.length > 0
|
||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||
`<option value="">Select a plane\u2026</option>` +
|
||||
groundedPlanes.map(
|
||||
planes.map(
|
||||
(pl) =>
|
||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||
).join('') +
|
||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
||||
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||
const popup = new maplibregl.Popup({ offset: 10 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setHTML(
|
||||
@@ -263,7 +264,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const el = popup.getElement();
|
||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
||||
// The status span is always in the popup HTML (even with no planes), so it
|
||||
// exists whenever the select/button do.
|
||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||
if (select && button && statusEl) {
|
||||
@@ -276,7 +277,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
button.disabled = true;
|
||||
statusEl.textContent = 'Relocating\u2026';
|
||||
try {
|
||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
||||
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destIcao: p.icao }),
|
||||
@@ -285,7 +290,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||
statusEl.textContent = 'Relocation started.';
|
||||
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||
} catch (err) {
|
||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||
button.disabled = false; // allow retry / picking another plane
|
||||
|
||||
+83
-3
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
||||
// 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 left-pane Relocate section (ISS-023, v0.21.0)
|
||||
// (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.
|
||||
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||
// 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)
|
||||
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||
|
||||
interface StateBody {
|
||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
@@ -195,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
||||
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);
|
||||
});
|
||||
|
||||
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
|
||||
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||||
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||||
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||||
// deterministic regardless of how fast the UI moves.
|
||||
const nearest = await nearestToHub(page);
|
||||
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||||
await setScale(page, 1);
|
||||
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||
expect(flyRes.ok()).toBeTruthy();
|
||||
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 API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||||
// the plane in the planes source — 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<string, unknown> }) => f.properties?.id === 'plane-starter');
|
||||
} catch {
|
||||
return false; // source not ready yet — keep polling
|
||||
}
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true);
|
||||
|
||||
const pt = await page.evaluate(({ lon, lat }) => {
|
||||
const map = (window as any).__flightMap;
|
||||
const p = map.project([lon, lat]);
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||
}, POPUP_AIRPORT);
|
||||
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 now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||||
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||||
const planeSel = page.getByTestId('airport-popup-plane');
|
||||
await expect(planeSel).toBeVisible();
|
||||
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/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();
|
||||
|
||||
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||||
// the flight queue while the plane keeps flying its original destination.
|
||||
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 && p.queue.includes(POPUP_AIRPORT.icao);
|
||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user