From c06e038130388d569ef562dbddb346ddefc571f3 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 21:35:48 +0000 Subject: [PATCH 1/6] =?UTF-8?q?v0.58.0=20=E2=80=94=20plane=20markers=20no?= =?UTF-8?q?=20longer=20teleport=20across=20the=20antimeridian=20(ISS-124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planesGeoJson now takes the airport index and unwraps each flying plane's longitude against its departure airport (unwrapLon, shared with unwrapLons), mirroring routesGeoJson's both-endpoints guard so a marker is unwrapped iff its line is drawn. Map.tsx passes airportsIndexRef at both call sites. Unit +6 in lib/mapData.test.ts; e2e +1 in e2e/map-interactions.spec.ts (chain-relocate to NZAA, fly the NZAA→NFTF crossing, sample marker lons). --- CHANGELOG.md | 4 + components/Map.tsx | 7 +- e2e/map-interactions.spec.ts | 140 +++++++++++++++++++++++++++++++++++ lib/mapData.test.ts | 95 +++++++++++++++++++++++- lib/mapData.ts | 29 ++++++-- package.json | 2 +- 6 files changed, 268 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9857b5..38f90d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.58.0 — 2026-09-22 21:27 +- **Plane markers no longer teleport across the antimeridian (ISS-124).** The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (`positionAt` wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. `planesGeoJson` in `lib/mapData.ts` now takes the airport index (`airportsByIcao`, same parameter shape as `routesGeoJson`) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor `greatCircleArc` keeps for its first sample — via a new module-private `unwrapLon(lon, prev)` (the ±360 shift until |Δlon| ≤ 180 that `unwrapLons`'s loop body already used; `unwrapLons` now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring `routesGeoJson`'s guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). `components/Map.tsx` passes `airportsIndexRef.current ?? new Map()` at both call sites (`applyDynamic` and the source bootstrap in `ensureLayers`) — planes draw immediately at their wrapped position until the airport index loads, then unwrap. +- **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state. + ## v0.57.0 — 2026-09-22 19:19 - **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). - **API.** OpenAPI spec gains `GET /api/events` (200 → `text/event-stream`) in `lib/openapi.ts`, so the machine-readable spec at `/api/openapi` stays accurate. diff --git a/components/Map.tsx b/components/Map.tsx index 024d927..50ef8ac 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -96,7 +96,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { const st = stateRef.current; if (!map || !st) return; const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; - if (planesSource) void planesSource.setData(planesGeoJson(st)); // v6 API (setGeoData was removed) + // ISS-124 (v0.58.0): the airport index unwraps crossing markers — empty until it loads, so + // planes draw at their wrapped position in the meantime (same as pre-v0.58.0). + if (planesSource) void planesSource.setData(planesGeoJson(st, airportsIndexRef.current ?? new Map())); // v6 API (setGeoData was removed) const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; if (routesSource && airportsIndexRef.current) { void routesSource.setData(routesGeoJson(st, airportsIndexRef.current)); @@ -405,7 +407,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { }); } if (!map.getSource(PLANES_SOURCE_ID)) { - map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) }); + // ISS-124 (v0.58.0): same airport-index unwrap as applyDynamic above. + map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()) }); } if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) { map.addLayer({ diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 3dfd525..a028f47 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -337,3 +337,143 @@ test('plane markers move while flying and the plane popup shows status + route', await loadAndFly(page, DEST, HUB); }); + +// ISS-124 (v0.58.0): a marker on a leg crossing the antimeridian must not teleport — its longitude is +// unwrapped against the departure airport in planesGeoJson (lib/mapData.ts), so consecutive polls stay +// continuous exactly like the route line. The starter can't fly EDDF→NZAA in one leg (~19,000 km vs the +// ATR72's ~3,700 km guaranteed range), so relocation is a fewest-legs chain over /api/airports with each +// hop ≤ MAX_LEG_KM (within that range even in worst-case headwind); set-fuel tops the tank for free before +// every dispatch so auto-refuel costs nothing. +const MAX_LEG_KM = 3400; + +// Fewest-legs chain from → to over /api/airports, each hop ≤ maxLegKm — BFS with adjacency sorted by +// (distance, ICAO) so the chain is deterministic for a given dataset. Excludes `from`, includes `to`. +async function chainTo(page: Page, fromIcao: string, toIcao: string): Promise { + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const aps = fc.features.map((f) => f.properties); + const toRad = (d: number) => (d * Math.PI) / 180; + const havKm = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => { + const dLat = toRad(b.lat - a.lat); + const dLon = toRad(b.lon - a.lon); + const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; + return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s))); + }; + const idx = new Map(aps.map((a, i) => [a.icao, i])); + const adj: Array> = aps.map(() => []); + for (let i = 0; i < aps.length; i++) { + for (let j = i + 1; j < aps.length; j++) { + const d = havKm(aps[i]!, aps[j]!); + if (d <= MAX_LEG_KM) { + adj[i]!.push([j, d]); + adj[j]!.push([i, d]); + } + } + } + for (const a of adj) a.sort((x, y) => x[1] - y[1] || aps[x[0]]!.icao.localeCompare(aps[y[0]]!.icao)); + const s = idx.get(fromIcao)!; + const t = idx.get(toIcao)!; + const prev = new Array(aps.length).fill(-1); + const queue: number[] = [s]; + for (let qi = 0; qi < queue.length && prev[t] === -1; qi++) { + const u = queue[qi]!; + for (const [v] of adj[u]!) { + if (prev[v] === -1) { + prev[v] = u; + queue.push(v); + } + } + } + expect(prev[t], `no ≤${MAX_LEG_KM} km chain from ${fromIcao} to ${toIcao}`).not.toBe(-1); + const path: string[] = []; + for (let cur = t; cur !== s; cur = prev[cur]!) path.unshift(aps[cur]!.icao); + return path; +} + +// plane-starter's marker from the planes source: its longitude plus whether that frame is a FLYING one. +// Extracted IN-PAGE because MapLibre feature geometry can be a lazy prototype getter that Playwright's +// evaluate serialization drops (same trap as e2e/hover.spec.ts). The `flying` flag matters for the +// sampling loop: a grounded frame snaps back to the raw airport coordinate (−175.1), which is the same +// screen point but a 360° jump in raw data — only flying frames may be recorded. +function starterMarker(page: Page): Promise<{ lon: number; flying: boolean } | null> { + return page.evaluate(() => { + const m = (window as any).__flightMap; + if (!m || !m.getSource('planes')) return null; + try { + for (const f of m.querySourceFeatures('planes') as Array<{ properties?: Record; geometry?: { coordinates: number[] } }>) { + if (f.properties?.id === 'plane-starter' && f.geometry) { + return { lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true }; + } + } + } catch { + // map/source not ready yet — null keeps the caller polling + } + return null; + }); +} + +test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { + test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin + + await page.goto('/'); + await waitForAirportCount(page); + + const ORIGIN = 'NZAA'; // Auckland — east of +170 + const CROSS_DEST = 'NFTF'; // Fua'amotu (Tonga) — west of −175; the short arc crosses ±180 EASTWARD + + // Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain. + expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); + if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') { + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); + } + let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; + if (loc !== ORIGIN) { + for (const next of await chainTo(page, loc, ORIGIN)) { + await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } + loc = ORIGIN; + } + + // The crossing package + dispatch. + await setFuelFull(page, 'plane-starter'); + const pkg = await createPackage(page, ORIGIN, CROSS_DEST); + expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } })).status()).toBe(200); + await setScale(page, SCALE); + + // Wait until the map's planes source shows it flying (the source updates from ~1 s state polls). + await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true); + + // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only + // FLYING frames — a grounded frame snaps back to its raw airport coordinate (−175.1), the same screen + // point but a 360° jump in raw data, and could land between this loop's server read and source read. + const lons: number[] = []; + for (;;) { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; + if (p.status !== 'flying') break; + const marker = await starterMarker(page); + if (marker?.flying) lons.push(marker.lon); + await page.waitForTimeout(500); + } + + expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one + for (let i = 1; i < lons.length; i++) { + expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug + } + expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) + expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window + + // Cleanup: chain back home so the other specs start from the canonical state. The package was delivered + // on arrival (generous deadline). + for (const next of await chainTo(page, CROSS_DEST, HUB)) { + await setFuelFull(page, 'plane-starter'); + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } +}); diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 48eeb62..fade72c 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -45,7 +45,7 @@ function snapshot(overrides: Partial = {}): StateSnapshot { describe('planesGeoJson', () => { it('returns an empty collection for a null snapshot', () => { - expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] }); + expect(planesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); it('emits one Point per plane at its polled lat/lon with popup properties', () => { @@ -56,6 +56,7 @@ describe('planesGeoJson', () => { { id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] }, ], }), + AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled ); expect(fc.features).toHaveLength(2); @@ -82,6 +83,7 @@ describe('planesGeoJson', () => { { id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, ], }), + AIRPORTS, ); expect(fc.features[0].properties.modelLabel).toBe('mystery'); }); @@ -93,11 +95,102 @@ describe('planesGeoJson', () => { { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, ], }), + AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled ); expect(fc.features[0].properties.routeText).toBe(''); }); }); +describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () => { + const nzaa = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8 + const nftf = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — NZAA→NFTF crosses ±180 eastward + + it('eastward crossing: a mid-leg wrapped lon is unwrapped against the origin airport', () => { + // positionAt wraps into [−180, 180]: past ±180 the snapshot reads −179.5 for a point at +180.5. + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + const [lon] = fc.features[0].geometry.coordinates; + expect(lon).toBeCloseTo(180.5, 9); // −179.5 + 360 — continuous with the origin's +174.8 + expect(Math.abs(lon - nzaa.lon)).toBeLessThan(180); // within half a turn of the anchor + }); + + it('westward crossing (reversed pair): unwrapped below −180', () => { + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + const [lon] = fc.features[0].geometry.coordinates; + expect(lon).toBeCloseTo(-180.5, 9); // +179.5 − 360 — continuous with the origin's −175.1 + }); + + it('marker lon equals the route line\'s own sample at every progress point (same world copy)', () => { + // The line is greatCircleArc(NZAA→NFTF) unwrapped from +174.8; feed each of its samples back as a + // WRAPPED snapshot position (what positionAt produces server-side) — the marker must land exactly + // on the line's sample, so marker and line agree frame to frame across the crossing. + const arc = greatCircleArc(nzaa, nftf); + for (const [uLon, uLat] of arc) { + let wLon = uLon; // wrap into positionAt's range as the server snapshot would carry it + while (wLon > 180) wLon -= 360; + while (wLon <= -180) wLon += 360; + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + expect(fc.features[0].geometry.coordinates[0]).toBeCloseTo(uLon, 9); + } + }); + + it('grounded planes keep their raw position (no unwrap without a leg)', () => { + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + expect(fc.features[0].geometry.coordinates).toEqual([nftf.lon, nftf.lat]); // raw −175.1, untouched + }); + + it('a flying plane with a missing endpoint keeps its wrapped lon (no line is drawn either)', () => { + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + expect(fc.features[0].geometry.coordinates).toEqual([-179.5, -29.5]); // wrapped — mirrors routesGeoJson's skip + }); + + it('non-crossing legs are untouched: lons stay as polled', () => { + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + expect(fc.features[0].geometry.coordinates).toEqual([-1.9, 45.2]); + }); +}); + describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => { it('returns an empty collection for a null snapshot', () => { expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); diff --git a/lib/mapData.ts b/lib/mapData.ts index d3d5a9c..5423cef 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -41,15 +41,30 @@ export interface FeatureCollection { const EMPTY_POINTS: FeatureCollection = { type: 'FeatureCollection', features: [] }; const EMPTY_LINES: FeatureCollection = { type: 'FeatureCollection', features: [] }; -export function planesGeoJson(state: StateSnapshot | null): FeatureCollection { +// ISS-124 (v0.58.0): positionAt wraps each sample's longitude into [−180, 180], so a marker on a +// leg crossing the antimeridian jumps +179.x → −179.x between polls — MapLibre draws that as a 360° +// teleport while the route line (greatCircleArc) stays continuous. Unwrap the plane's lon against +// its departure airport — the same anchor greatCircleArc keeps for its first sample — so marker and +// line agree frame to frame. Only when BOTH endpoints exist, mirroring routesGeoJson's guard: a +// flying plane with a missing endpoint draws no line either, so it stays wrapped like before. +export function planesGeoJson( + state: StateSnapshot | null, + airportsByIcao: ReadonlyMap, +): FeatureCollection { if (!state) return EMPTY_POINTS; // Catalog display name ("ATR 72-600") for the popup — raw model id as fallback. const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label])); const features: MapPointFeature[] = state.planes.map((p) => { const flying = p.status === 'flying'; + let lon = p.lon; + if (flying && p.destIcao) { + const from = airportsByIcao.get(p.locationIcao); + const to = airportsByIcao.get(p.destIcao); + if (from && to) lon = unwrapLon(p.lon, from.lon); + } return { type: 'Feature', - geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, + geometry: { type: 'Point', coordinates: [lon, p.lat] }, properties: { id: p.id, modelLabel: labelByModel.get(p.model) ?? p.model, @@ -80,12 +95,16 @@ export const ROUTE_ARC_SEGMENTS = 24; // sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent // one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line; // out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on). +function unwrapLon(lon: number, prev: number): number { + while (lon - prev > 180) lon -= 360; + while (prev - lon > 180) lon += 360; + return lon; +} + function unwrapLons(coords: [number, number][]): [number, number][] { let prev = coords[0]![0]; for (let i = 1; i < coords.length; i++) { - let lon = coords[i]![0]; - while (lon - prev > 180) lon -= 360; - while (prev - lon > 180) lon += 360; + const lon = unwrapLon(coords[i]![0], prev); coords[i]![0] = lon; prev = lon; } diff --git a/package.json b/package.json index 3523fa6..811144c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.57.0", + "version": "0.58.0", "private": true, "scripts": { "dev": "next dev -p 3000", From baa4b0fa7c6edf405019c521902c2d923e93c406 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 22:58:03 +0000 Subject: [PATCH 2/6] e2e: fit camera over the crossing leg before querying the planes source; cleanup always runs (run-176) --- CHANGELOG.md | 1 + e2e/map-interactions.spec.ts | 138 ++++++++++++++++++++++++----------- lib/mapData.test.ts | 14 ++++ 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f90d8..e1fa557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.58.0 — 2026-09-22 21:27 - **Plane markers no longer teleport across the antimeridian (ISS-124).** The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (`positionAt` wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. `planesGeoJson` in `lib/mapData.ts` now takes the airport index (`airportsByIcao`, same parameter shape as `routesGeoJson`) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor `greatCircleArc` keeps for its first sample — via a new module-private `unwrapLon(lon, prev)` (the ±360 shift until |Δlon| ≤ 180 that `unwrapLons`'s loop body already used; `unwrapLons` now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring `routesGeoJson`'s guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). `components/Map.tsx` passes `airportsIndexRef.current ?? new Map()` at both call sites (`applyDynamic` and the source bootstrap in `ensureLayers`) — planes draw immediately at their wrapped position until the airport index loads, then unwrap. - **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state. +- **E2E fix (CI run 176).** The new antimeridian test failed deterministically in CI — not a marker bug, but a query trap: MapLibre's `querySourceFeatures` only returns features in LOADED tiles, and GeoJSON sources load per viewport tile; the default camera sits over Europe while NZAA/NFTF are in the Pacific, so the plane feature never appeared in queries and the "flying" poll timed out (the same pattern passes for EDDF→LEMD because that leg is inside the default view). The test now fits the camera over both endpoints first via `fitToAirports` (copied from `e2e/hover.spec.ts`, including its ISS-120 unwrap so a ±180-straddling pair bounds the Pacific, not the Atlantic) before polling and sampling. Pollution guard: relocation + crossing now run in a `try/finally` whose cleanup ALWAYS runs — it waits for any in-flight leg to land (scale 1000), then chains the plane back to EDDF from wherever it is, so a failed poll can no longer leave the starter FLYING and 409-cascade into the eleven later specs' fly-home helpers (the run-176 cascade). The dispatch POST's status is now checked explicitly with a clear message instead of a bare `expect(...).toBe(200)`. Unit +1 in `lib/mapData.test.ts` pins the exact feature properties the e2e `starterMarker` matches on (`properties.id === 'plane-starter'`, `properties.flying === true`). ## v0.57.0 — 2026-09-22 19:19 - **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index a028f47..d82e812 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -120,7 +120,8 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise return pkg; } -// All features of a GeoJSON source (viewport-independent), [] until the map/source exists. +// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over +// the area of interest first ([] until then). Same trap as run-176: Europe view + Pacific plane = []. function sourceFeatures(page: Page, sourceId: string) { return page.evaluate((srcId: string) => { const m = (window as any).__flightMap; @@ -413,6 +414,42 @@ function starterMarker(page: Page): Promise<{ lon: number; flying: boolean } | n }); } +// Jump the camera to a box around the given airports. Copied from e2e/hover.spec.ts — that version +// unwraps crossing pairs (ISS-120) so a ±180-straddling pair fits the Pacific, not the Atlantic. +async function fitToAirports(page: Page, icaos: string[]): Promise { + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const points = icaos.map((icao) => { + const f = fc.features.find((x) => x.properties.icao === icao); + if (!f) throw new Error(`airport ${icao} not found in /api/airports`); + return [f.properties.lon, f.properties.lat] as [number, number]; + }); + for (let i = 1; i < points.length; i++) { + let lon = points[i]![0]; + const prev = points[i - 1]![0]; + while (lon - prev > 180) lon -= 360; + while (prev - lon > 180) lon += 360; + points[i]![0] = lon; + } + await page.evaluate((pts: [number, number][]) => { + const m = (window as any).__flightMap; + if (!m) throw new Error('map not ready'); + const lons = pts.map((p) => p[0]); + const lats = pts.map((p) => p[1]); + // duration 0 → instant camera jump, no animation to wait out. + m.fitBounds( + [ + [Math.min(...lons), Math.min(...lats)], + [Math.max(...lons), Math.max(...lats)], + ], + { padding: 40, duration: 0 }, + ); + }, points); + // Give the renderer a beat after the camera jump before asserting on rendered features. + await page.waitForTimeout(300); +} + test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin @@ -428,52 +465,65 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' await setScale(page, SCALE); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); } - let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; - if (loc !== ORIGIN) { - for (const next of await chainTo(page, loc, ORIGIN)) { - await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing - expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); - await setScale(page, SCALE); - await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + // Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end + // grounded at HUB so no later spec's fly-home gets a 409 (the run-176 pollution cascade). + try { + let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; + if (loc !== ORIGIN) { + for (const next of await chainTo(page, loc, ORIGIN)) { + await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } } - loc = ORIGIN; - } - // The crossing package + dispatch. - await setFuelFull(page, 'plane-starter'); - const pkg = await createPackage(page, ORIGIN, CROSS_DEST); - expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); - expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } })).status()).toBe(200); - await setScale(page, SCALE); - - // Wait until the map's planes source shows it flying (the source updates from ~1 s state polls). - await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true); - - // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only - // FLYING frames — a grounded frame snaps back to its raw airport coordinate (−175.1), the same screen - // point but a 360° jump in raw data, and could land between this loop's server read and source read. - const lons: number[] = []; - for (;;) { - const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; - if (p.status !== 'flying') break; - const marker = await starterMarker(page); - if (marker?.flying) lons.push(marker.lon); - await page.waitForTimeout(500); - } - - expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one - for (let i = 1; i < lons.length; i++) { - expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug - } - expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) - expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window - - // Cleanup: chain back home so the other specs start from the canonical state. The package was delivered - // on arrival (generous deadline). - for (const next of await chainTo(page, CROSS_DEST, HUB)) { + // The crossing package + dispatch. await setFuelFull(page, 'plane-starter'); - expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + const pkg = await createPackage(page, ORIGIN, CROSS_DEST); + expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); + const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } }); + if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`); await setScale(page, SCALE); - await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + + // querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the + // Pacific: fit the camera over both endpoints first or the plane feature never appears in queries. + await fitToAirports(page, [ORIGIN, CROSS_DEST]); + + // Wait until the map's planes source shows it flying (the source updates from ~1 s state polls). + await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true); + + // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only + // FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen + // point but a 360° jump in raw data, and could land between this loop's server read and source read. + const lons: number[] = []; + for (;;) { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; + if (p.status !== 'flying') break; + const marker = await starterMarker(page); + if (marker?.flying) lons.push(marker.lon); + await page.waitForTimeout(500); + } + + expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one + for (let i = 1; i < lons.length; i++) { + expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug + } + expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) + expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window + } finally { + // Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical + // state. The package was delivered on arrival (generous deadline). + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); + const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; + if (endLoc !== HUB) { + for (const next of await chainTo(page, endLoc, HUB)) { + await setFuelFull(page, 'plane-starter'); + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } + } } }); diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index fade72c..3971962 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -189,6 +189,20 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () => ); expect(fc.features[0].geometry.coordinates).toEqual([-1.9, 45.2]); }); + + it('emits the exact properties e2e starterMarker matches on: id + flying', () => { + const fc = planesGeoJson( + snapshot({ + planes: [ + { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }), + AIRPORTS, + ); + const props = fc.features[0].properties!; + expect(props.id).toBe('plane-starter'); // starterMarker's match key + expect(props.flying).toBe(true); // the flag the e2e poll waits on + }); }); describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => { From 7671c028b446350a907dd50fbb20964253266c7a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 00:32:36 +0000 Subject: [PATCH 3/6] =?UTF-8?q?e2e:=20reload=20before=20the=20crossing=20l?= =?UTF-8?q?eg=20+=20freshness=20gate=20on=20sampled=20frames=20(run-177);?= =?UTF-8?q?=20queue=20refuel=20bound=201.4=E2=86=921.3=C3=97required2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + e2e/map-interactions.spec.ts | 25 +++++++++++++++++++------ e2e/queue.spec.ts | 4 +++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fa557..c27544a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **Plane markers no longer teleport across the antimeridian (ISS-124).** The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (`positionAt` wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. `planesGeoJson` in `lib/mapData.ts` now takes the airport index (`airportsByIcao`, same parameter shape as `routesGeoJson`) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor `greatCircleArc` keeps for its first sample — via a new module-private `unwrapLon(lon, prev)` (the ±360 shift until |Δlon| ≤ 180 that `unwrapLons`'s loop body already used; `unwrapLons` now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring `routesGeoJson`'s guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). `components/Map.tsx` passes `airportsIndexRef.current ?? new Map()` at both call sites (`applyDynamic` and the source bootstrap in `ensureLayers`) — planes draw immediately at their wrapped position until the airport index loads, then unwrap. - **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state. - **E2E fix (CI run 176).** The new antimeridian test failed deterministically in CI — not a marker bug, but a query trap: MapLibre's `querySourceFeatures` only returns features in LOADED tiles, and GeoJSON sources load per viewport tile; the default camera sits over Europe while NZAA/NFTF are in the Pacific, so the plane feature never appeared in queries and the "flying" poll timed out (the same pattern passes for EDDF→LEMD because that leg is inside the default view). The test now fits the camera over both endpoints first via `fitToAirports` (copied from `e2e/hover.spec.ts`, including its ISS-120 unwrap so a ±180-straddling pair bounds the Pacific, not the Atlantic) before polling and sampling. Pollution guard: relocation + crossing now run in a `try/finally` whose cleanup ALWAYS runs — it waits for any in-flight leg to land (scale 1000), then chains the plane back to EDDF from wherever it is, so a failed poll can no longer leave the starter FLYING and 409-cascade into the eleven later specs' fly-home helpers (the run-176 cascade). The dispatch POST's status is now checked explicitly with a clear message instead of a bare `expect(...).toBe(200)`. Unit +1 in `lib/mapData.test.ts` pins the exact feature properties the e2e `starterMarker` matches on (`properties.id === 'plane-starter'`, `properties.flying === true`). +- **E2E fix (CI run 177).** The antimeridian test still failed — again not a marker bug: by dispatch time the test's page had been alive ~3 min through the relocation chain, and under dind load the browser's state feed can go stale (the SSE fallback in `lib/useGameState.ts` only re-arms polling on stream error or a one-shot 5 s silence check at connect), leaving the planes source frozen on an old FLYING frame from a relocation hop — which fooled the "wait for flying" poll and made every sampled longitude a stale pre-crossing value (continuous, all ≤ 180, never > 180). The test now reloads the page after relocation (server state persists; on the fresh page only the crossing leg can ever produce a flying frame), and the sampling loop gains a freshness gate: a sample is recorded only if its marker position is within ~700 km of where the SERVER snapshot says the plane is (~5 s of flight at ×1000 — haversine is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly), so a frozen frame can no longer pass continuity vacuously; `starterMarker` now also returns latitude for that check. Pre-existing flake fixed in the same run: `e2e/queue.spec.ts`'s chain-refuel lower bound loosened from `1.4 × required2` to `1.3 × required2` — under dind load the post-departure snapshot can land ~0.1 × required2 into leg 2's burn (observed miss: 0.07 %); a wrong top-up level still lands far below the bound, and the exact level remains pinned by the upper bound plus the rep-discounted cash assertion. ## v0.57.0 — 2026-09-22 19:19 - **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index d82e812..2469a41 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -392,19 +392,20 @@ async function chainTo(page: Page, fromIcao: string, toIcao: string): Promise { +// screen point but a 360° jump in raw data — only flying frames may be recorded. `lat` feeds the +// freshness gate below (a stale source frame sits far from where the SERVER says the plane is). +function starterMarker(page: Page): Promise<{ lat: number; lon: number; flying: boolean } | null> { return page.evaluate(() => { const m = (window as any).__flightMap; if (!m || !m.getSource('planes')) return null; try { for (const f of m.querySourceFeatures('planes') as Array<{ properties?: Record; geometry?: { coordinates: number[] } }>) { if (f.properties?.id === 'plane-starter' && f.geometry) { - return { lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true }; + return { lat: f.geometry.coordinates[1]!, lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true }; } } } catch { @@ -478,6 +479,14 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' } } + // Fresh page before the crossing leg: this test's page has been alive ~3 min (relocation chain) and + // under dind load the browser's state feed can go stale — a frozen planes source would then keep an + // old FLYING frame from a relocation hop, fooling the flying poll below and sampling stale lons. + // Server state persists across reloads; the plane is grounded at ORIGIN here, so on this fresh page + // ONLY the crossing leg can ever produce a flying frame. + await page.reload(); + await waitForAirportCount(page); + // The crossing package + dispatch. await setFuelFull(page, 'plane-starter'); const pkg = await createPackage(page, ORIGIN, CROSS_DEST); @@ -496,16 +505,20 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only // FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen // point but a 360° jump in raw data, and could land between this loop's server read and source read. + // Freshness gate: the browser's source may lag server truth by a tick or two (SSE + tile re-render), + // but a FROZEN frame would otherwise pass continuity vacuously and never show the crossing — accept + // only frames within ~700 km of where the SERVER says the plane is (~5 s of flight at ×1000); haversine + // is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly. const lons: number[] = []; for (;;) { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; if (p.status !== 'flying') break; const marker = await starterMarker(page); - if (marker?.flying) lons.push(marker.lon); + if (marker?.flying && haversineKm(p.lat, p.lon, marker.lat, marker.lon) < 700) lons.push(marker.lon); await page.waitForTimeout(500); } - expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one + expect(lons.length).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), not a degenerate one for (let i = 1; i < lons.length; i++) { expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug } diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 5d59e15..0f43891 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -572,7 +572,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou // Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below. const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel; expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure… - expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound + // 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn; + // a wrong top-up level (≤ required2, or none at all) still lands far below this bound. + expect(fuel).toBeGreaterThan(1.3 * required2); expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost // Let leg 2 finish so the next test starts from a known airport. From d0aec35db1fd2260922047f31105b261853e91eb Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 01:59:54 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(useGameState):=20re-arm=20SSE=20silence?= =?UTF-8?q?=20check=20on=20every=20message=20=E2=80=94=20a=20stream=20that?= =?UTF-8?q?=20goes=20silent=20after=20the=20first=20event=20must=20fall=20?= =?UTF-8?q?back=20to=20polling=20again=20(run-178=20root=20cause);=20unit:?= =?UTF-8?q?=20pin=20NZAA=E2=86=92NFTF=20full-pipeline=20marker=20lons=20on?= =?UTF-8?q?=20stored=20dataset=20coords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ lib/mapData.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ lib/useGameState.ts | 3 ++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27544a..7445d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state. - **E2E fix (CI run 176).** The new antimeridian test failed deterministically in CI — not a marker bug, but a query trap: MapLibre's `querySourceFeatures` only returns features in LOADED tiles, and GeoJSON sources load per viewport tile; the default camera sits over Europe while NZAA/NFTF are in the Pacific, so the plane feature never appeared in queries and the "flying" poll timed out (the same pattern passes for EDDF→LEMD because that leg is inside the default view). The test now fits the camera over both endpoints first via `fitToAirports` (copied from `e2e/hover.spec.ts`, including its ISS-120 unwrap so a ±180-straddling pair bounds the Pacific, not the Atlantic) before polling and sampling. Pollution guard: relocation + crossing now run in a `try/finally` whose cleanup ALWAYS runs — it waits for any in-flight leg to land (scale 1000), then chains the plane back to EDDF from wherever it is, so a failed poll can no longer leave the starter FLYING and 409-cascade into the eleven later specs' fly-home helpers (the run-176 cascade). The dispatch POST's status is now checked explicitly with a clear message instead of a bare `expect(...).toBe(200)`. Unit +1 in `lib/mapData.test.ts` pins the exact feature properties the e2e `starterMarker` matches on (`properties.id === 'plane-starter'`, `properties.flying === true`). - **E2E fix (CI run 177).** The antimeridian test still failed — again not a marker bug: by dispatch time the test's page had been alive ~3 min through the relocation chain, and under dind load the browser's state feed can go stale (the SSE fallback in `lib/useGameState.ts` only re-arms polling on stream error or a one-shot 5 s silence check at connect), leaving the planes source frozen on an old FLYING frame from a relocation hop — which fooled the "wait for flying" poll and made every sampled longitude a stale pre-crossing value (continuous, all ≤ 180, never > 180). The test now reloads the page after relocation (server state persists; on the fresh page only the crossing leg can ever produce a flying frame), and the sampling loop gains a freshness gate: a sample is recorded only if its marker position is within ~700 km of where the SERVER snapshot says the plane is (~5 s of flight at ×1000 — haversine is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly), so a frozen frame can no longer pass continuity vacuously; `starterMarker` now also returns latitude for that check. Pre-existing flake fixed in the same run: `e2e/queue.spec.ts`'s chain-refuel lower bound loosened from `1.4 × required2` to `1.3 × required2` — under dind load the post-departure snapshot can land ~0.1 × required2 into leg 2's burn (observed miss: 0.07 %); a wrong top-up level still lands far below the bound, and the exact level remains pinned by the upper bound plus the rep-discounted cash assertion. +- **Root cause of the stale-feed freeze fixed in `lib/useGameState.ts` (CI run 178).** The reload + freshness gate above were insurance; run 178 proved the feed can freeze even on a FRESH page — and the reason is now fixed at the source, not worked around. The SSE silence check was one-shot: `setTimeout(startPolling, 5 s)` armed once at connect, cleared by the FIRST event and never re-armed. After that, only a stream ERROR could restart polling — but a half-open connection that simply stops delivering data (observed under dind load; `/api/state` on separate connections kept answering fine throughout) fires no `onerror`, so state froze until reload. The timer is now re-armed on EVERY message: any 5 s gap in the event flow — whenever it happens, not just before the first event — starts the polling fallback (immediate catch-up fetch + interval), which stops again when fresh events resume. Healthy streams pay one set/clearTimeout per event; stalled ones degrade to the pre-v0.57.0 poll loop instead of freezing. The e2e keeps both insurance layers: the reload still guarantees a clean SSE connect at dispatch, and the freshness gate still fails loudly if any future stall outlasts it (a frozen source can no longer pass continuity vacuously). +- **Tests.** Unit +1 in `lib/mapData.test.ts` ("NZAA→NFTF crossing leg — full pipeline on stored dataset coords"): runs the exact production pipeline on the STORED CSV coordinates (`loadAirportsGeoJson` → `positionAt` slerp sampled at 51 steps → `planesGeoJson`) and pins what a live browser source must show — marker lons monotonically increasing from +174.8 to +184.9 (the unwrapped NFTF), every consecutive pair < 180° apart, max > 180 (eastward crossing onto the world copy). This is the ground truth the e2e's `lon > 180` assertion encodes: the dataset crosses EASTWARD (NZAA +174.786 → NFTF −175.149, Δ ≈ +10°), so a correct pipeline MUST produce lons above +180 — and any e2e failure of that assertion is now provably a stale browser source, not the math. ## v0.57.0 — 2026-09-22 19:19 - **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 3971962..48c2744 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import type { StateSnapshot } from './game/state'; import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; import { haversineKm } from './geo'; +import { loadAirportsGeoJson } from './airports'; +import { positionAt } from './sim/movement'; +import { PLANE_TYPES } from './game/catalog'; const AIRPORTS = new Map([ ['EDDF', { lat: 50.03, lon: 8.56 }], @@ -503,3 +506,39 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => { expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9); }); }); + +describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (ISS-124)', () => { + // The exact production pipeline for one flying frame: the server snapshot holds positionAt's WRAPPED + // lat/lon (atan2 → [−180, 180]); planesGeoJson re-expresses lon against the departure airport + // (locationIcao is always the leg origin while flying — lib/game/state.ts). This pins what lons the + // e2e marker sampling can EVER see for this dataset's stored coords. + it('marker lons cross above +180 eastward, continuously', async () => { + const fc = await loadAirportsGeoJson(); + const nzaa = fc.features.find((f) => f.properties.icao === 'NZAA')!.properties; + const nftf = fc.features.find((f) => f.properties.icao === 'NFTF')!.properties; + expect(nzaa.lon).toBeGreaterThan(170); // stored east-positive (Auckland +174.8, not −175.x) + expect(nftf.lon).toBeLessThan(-170); + + const km = haversineKm(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon); + const index = new Map([ + ['NZAA', { lat: nzaa.lat, lon: nzaa.lon }], + ['NFTF', { lat: nftf.lat, lon: nftf.lon }], + ]); + const lons: number[] = []; + for (let i = 0; i <= 50; i++) { + const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds + const snap = snapshot({ + planes: [ + { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [] }, + ], + }); + lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!); + } + console.log(`NZAA→NFTF (${km.toFixed(1)} km): sampled marker lons ${JSON.stringify(lons.map((x) => Number(x.toFixed(2))))}`); + for (let i = 1; i < lons.length; i++) { + expect(Math.abs(lons[i]! - lons[i - 1]!), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180); + } + expect(Math.max(...lons)).toBeGreaterThan(180); // eastward crossing → onto the + world copy + expect(Math.min(...lons)).toBeGreaterThanOrEqual(nzaa.lon - 1e-9); // never wraps to the west copy + }); +}); diff --git a/lib/useGameState.ts b/lib/useGameState.ts index d72e6c1..f67d780 100644 --- a/lib/useGameState.ts +++ b/lib/useGameState.ts @@ -106,7 +106,8 @@ export function useGameState(intervalMs = 1000): GameStateResult { // malformed event — the next one overwrites it anyway } stopPolling(); // fresh push flow → polling no longer needed - if (silenceTimer !== null) clearTimeout(silenceTimer); // connect check served its purpose + if (silenceTimer !== null) clearTimeout(silenceTimer); + silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // re-arm on EVERY message: a stream that goes silent after the first event (half-open connection — no onerror fires) must fall back to polling again, or state freezes until reload }; source.onerror = () => startPolling(); // stream trouble → poll until pushes resume silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll From d0d764e0c1928d3213a21bc5029f8f8d1b9023cf Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 02:48:32 +0000 Subject: [PATCH 5/6] =?UTF-8?q?e2e(antimeridian):=20run-179=20data=20colle?= =?UTF-8?q?ction=20=E2=80=94=20unconditional=20server/source=20diagnostics?= =?UTF-8?q?,=20values=20in=20expect=20messages,=20and=20wait-for-flying=20?= =?UTF-8?q?now=20requires=20SERVER=20state=20on=20the=20specific=20ORIGIN-?= =?UTF-8?q?>CROSS=5FDEST=20leg=20(marker-only=20poll=20could=20pass=20on?= =?UTF-8?q?=20a=20stale=20hop=20frame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/map-interactions.spec.ts | 54 +++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 2469a41..b8e69ae 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -487,20 +487,32 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' await page.reload(); await waitForAirportCount(page); - // The crossing package + dispatch. + // The crossing package + dispatch. RUN-179 DATA COLLECTION: server-side truth is logged at every + // phase (unconditionally — Playwright puts test stdout in the job log for failed tests), so a failed + // run shows exactly what /api/state said while the browser source was sampled. + const fmtPlane = (p: PlaneState) => `status=${p.status} loc=${p.locationIcao} dest=${p.destIcao ?? '-'} lat=${p.lat.toFixed(4)} lon=${p.lon.toFixed(4)}`; await setFuelFull(page, 'plane-starter'); const pkg = await createPackage(page, ORIGIN, CROSS_DEST); expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); + console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } }); if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`); await setScale(page, SCALE); + console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); // querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the // Pacific: fit the camera over both endpoints first or the plane feature never appears in queries. await fitToAirports(page, [ORIGIN, CROSS_DEST]); - // Wait until the map's planes source shows it flying (the source updates from ~1 s state polls). - await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true); + // Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND + // the planes source agrees. A marker-only poll could pass on a stale FLYING frame from an earlier hop; + // sampling must start only when this leg is actually underway server-side. + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; + if (!(p.status === 'flying' && p.locationIcao === ORIGIN && p.destIcao === CROSS_DEST)) return false; + return (await starterMarker(page))?.flying ?? false; + }, { timeout: 30_000, message: `server must show plane-starter flying ${ORIGIN}->${CROSS_DEST} AND the planes source must show it flying` }).toBe(true); + console.log(`[antimeridian] FLYING first observed (server): ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only // FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen @@ -510,20 +522,48 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' // only frames within ~700 km of where the SERVER says the plane is (~5 s of flight at ×1000); haversine // is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly. const lons: number[] = []; + const lats: number[] = []; + let rawSamples = 0; + const rejected: string[] = []; + const acceptedDetail: string[] = []; for (;;) { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; if (p.status !== 'flying') break; + rawSamples++; const marker = await starterMarker(page); - if (marker?.flying && haversineKm(p.lat, p.lon, marker.lat, marker.lon) < 700) lons.push(marker.lon); + if (!marker || !marker.flying) { + rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker=${marker ? `not-flying (${marker.lat.toFixed(4)},${marker.lon.toFixed(4)})` : 'null'}`); + } else { + const d = haversineKm(p.lat, p.lon, marker.lat, marker.lon); + if (d < 700) { + lons.push(marker.lon); + lats.push(marker.lat); + // Δlon ≈ 0 → source tracks the wrapped server lon (unwrap NOT applied or pre-crossing); + // Δlon ≈ +360 post-crossing → unwrap IS applied to what starterMarker reads. + acceptedDetail.push(`server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) source(lon=${marker.lon.toFixed(4)} lat=${marker.lat.toFixed(4)}) Δlon=${(marker.lon - p.lon).toFixed(2)}`); + } else { + rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker(${marker.lat.toFixed(4)},${marker.lon.toFixed(4)}) dist=${d.toFixed(1)}km ≥700`); + } + } await page.waitForTimeout(500); } - expect(lons.length).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), not a degenerate one + // Unconditional diagnostics (run-179): full arrays + raw/accepted/rejected counts, so the failure log + // maps directly to a cause: constant ≈174.8 → frozen pre-crossing frame; values below −180 → westward + // unwrap; > 180 with Δlon≈+360 → correct eastward unwrap; empty after gate → source never fresh. + console.log(`[antimeridian] sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`); + console.log(`[antimeridian] sampled lats (n=${lats.length}): ${JSON.stringify(lats)}`); + console.log(`[antimeridian] raw samples=${rawSamples} accepted=${lons.length} rejected=${rejected.length}`); + for (const r of rejected) console.log(`[antimeridian] REJECTED: ${r}`); + const detail = acceptedDetail.length <= 10 ? acceptedDetail : [...acceptedDetail.slice(0, 5), '…', ...acceptedDetail.slice(-5)]; + for (const a of detail) console.log(`[antimeridian] ACCEPTED: ${a}`); + + expect(lons.length, `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}; rejected: ${rejected.join(' | ')}`).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), not a degenerate one for (let i = 1; i < lons.length; i++) { expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug } - expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) - expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window + expect(lons.some((lon) => lon > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) + expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window } finally { // Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical // state. The package was delivered on arrival (generous deadline). From 44996b434f81cd4f0da8c5d4f4cb6dec7c6e02de Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 05:17:11 +0000 Subject: [PATCH 6/6] =?UTF-8?q?e2e(antimeridian):=20crossing=20assertion?= =?UTF-8?q?=20copy-agnostic=20(|lon|=20>=20180)=20=E2=80=94=20run-180=20pr?= =?UTF-8?q?oved=20the=20product=20correct:=20sampled=20lons=20[=E2=88=9218?= =?UTF-8?q?4.75=E2=80=A6=E2=88=92175.24]=20are=20a=20continuous=20western?= =?UTF-8?q?=20world=20copy,=20only=20the=20'east=20copy'=20assumption=20wa?= =?UTF-8?q?s=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/map-interactions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index b8e69ae..ec0bc76 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -562,7 +562,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' for (let i = 1; i < lons.length; i++) { expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug } - expect(lons.some((lon) => lon > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) + expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180 expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window } finally { // Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical