Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9e9ade58 | ||
|
|
6a819b8d5a | ||
|
|
c5413aff65 | ||
|
|
44996b434f | ||
|
|
d0d764e0c1 | ||
|
|
d0aec35db1 | ||
|
|
7671c028b4 | ||
|
|
baa4b0fa7c | ||
|
|
c06e038130 |
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.59.0 — 2026-09-23 05:48
|
||||||
|
- **SSE frames no longer carry package history (ISS-133).** Measured on the live instance first: each `/api/events` frame was compact JSON at ~597 KB, pushed once per real second regardless of time scale (the background tick is a fixed 1 s interval; ×1000 just advances the clock further per frame) — and 99.8 % of it was `packages`: 1,521 rows because terminal packages are never deleted from the DB (expired 564, delivered 528, ignored 386, abandoned 31), while only 12 were `open`. No component ever reads a package with a terminal status — the market list renders `open`, and on-board cargo is `loaded`/`expired` (`LeftPane`, `cargoWeightFor`) — so the history was pure re-sent weight. New `sseFrame(snapshot)` in `lib/game/state.ts` projects a snapshot down to the client-rendered statuses (`SSE_PACKAGE_STATUSES = open|loaded|expired`); both SSE paths use it — the broadcast in `lib/sim/engine.ts` and the initial frame in `app/api/events/route.ts`, so a connect never costs one full-history frame. Live payload drops ~597 KB → ~10 KB per frame (≈ 60×), bounded forever by fleet/market size instead of growing with play time. `/api/state` and action responses keep the FULL package set unchanged, so every e2e oracle that asserts `delivered`/`abandoned` transitions via state lookups still works; the polling fallback stays a strict superset of what SSE pushes. Deliberately NOT done: hash-skip broadcasts (the snapshot's `clockMs` advances on every tick even while idle — `advanceMs = min(elapsed, 2000 ms) × scale ≥ 1 s` — so no two frames are ever equal), throttling (already exactly 1 Hz), and a diff/patch engine.
|
||||||
|
- **Tests.** Unit +3 in new `lib/game/state.test.ts`: the projection keeps exactly open/loaded/expired (order preserved, terminal rows dropped), passes account/simState/planes through untouched without mutating its input, and pins the status set itself. E2E: `e2e/events.spec.ts` now records every package status seen in any frame plus the largest frame — asserts no terminal status ever appears and frames stay under 200 KB (a full-history regression at ~597 KB fails loudly).
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
- **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
|
## 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: <json>\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).
|
- **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: <json>\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.
|
- **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.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
||||||
import { subscribe } from '@/lib/sse';
|
import { subscribe } from '@/lib/sse';
|
||||||
|
|
||||||
// ISS-129 (v0.57.0): Server-Sent Events feed of full state snapshots — the current snapshot on
|
// ISS-129 (v0.57.0): Server-Sent Events feed of state snapshots — the current snapshot on connect,
|
||||||
// connect, then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player
|
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
|
||||||
// actions stay REST POSTs. Heartbeats keep idle proxies from dropping the connection.
|
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
|
||||||
|
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
|
||||||
|
// pushed frames, so a connect never costs one full-history frame.
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const HEARTBEAT_MS = 15_000;
|
const HEARTBEAT_MS = 15_000;
|
||||||
@@ -39,7 +41,7 @@ export async function GET() {
|
|||||||
// same as above
|
// same as above
|
||||||
}
|
}
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(snapshot)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(sseFrame(snapshot))}\n\n`));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
+5
-2
@@ -96,7 +96,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const st = stateRef.current;
|
const st = stateRef.current;
|
||||||
if (!map || !st) return;
|
if (!map || !st) return;
|
||||||
const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
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;
|
const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
if (routesSource && airportsIndexRef.current) {
|
if (routesSource && airportsIndexRef.current) {
|
||||||
void routesSource.setData(routesGeoJson(st, 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)) {
|
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)) {
|
if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl';
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
simState: { clockMs: number };
|
simState: { clockMs: number };
|
||||||
|
packages?: Array<{ status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history
|
||||||
|
// (delivered/abandoned/ignored) is unbounded in the DB and must never leak into a pushed frame.
|
||||||
|
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
|
||||||
|
|
||||||
test('GET /api/events streams state snapshots over SSE', async () => {
|
test('GET /api/events streams state snapshots over SSE', async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
|
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -20,6 +25,8 @@ test('GET /api/events streams state snapshots over SSE', async () => {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
const clocks: number[] = []; // simState.clockMs of each parsed snapshot event
|
const clocks: number[] = []; // simState.clockMs of each parsed snapshot event
|
||||||
|
const statusesSeen = new Set<string>(); // package statuses observed in any frame (ISS-133)
|
||||||
|
let maxFrameBytes = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect two snapshot events: the initial one, then at least one pushed by a background tick.
|
// Collect two snapshot events: the initial one, then at least one pushed by a background tick.
|
||||||
@@ -46,6 +53,8 @@ test('GET /api/events streams state snapshots over SSE', async () => {
|
|||||||
if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping")
|
if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping")
|
||||||
const snap = JSON.parse(line.slice('data: '.length)) as StateBody;
|
const snap = JSON.parse(line.slice('data: '.length)) as StateBody;
|
||||||
clocks.push(snap.simState.clockMs);
|
clocks.push(snap.simState.clockMs);
|
||||||
|
for (const p of snap.packages ?? []) statusesSeen.add(p.status);
|
||||||
|
maxFrameBytes = Math.max(maxFrameBytes, line.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,4 +65,11 @@ test('GET /api/events streams state snapshots over SSE', async () => {
|
|||||||
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
||||||
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(clocks[1]).toBeGreaterThan(clocks[0]); // clock advanced between pushes — real updates, not replays
|
expect(clocks[1]).toBeGreaterThan(clocks[0]); // clock advanced between pushes — real updates, not replays
|
||||||
|
|
||||||
|
// ISS-133: no terminal package history in any frame (the DB keeps it forever; frames must not),
|
||||||
|
// and a bounded frame size even on an instance with long-running history (~597 KB pre-v0.59.0).
|
||||||
|
for (const status of statusesSeen) {
|
||||||
|
expect(ALLOWED_PACKAGE_STATUSES.has(status)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(maxFrameBytes).toBeLessThan(200_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
|
|||||||
return pkg;
|
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) {
|
function sourceFeatures(page: Page, sourceId: string) {
|
||||||
return page.evaluate((srcId: string) => {
|
return page.evaluate((srcId: string) => {
|
||||||
const m = (window as any).__flightMap;
|
const m = (window as any).__flightMap;
|
||||||
@@ -337,3 +338,245 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
|
|
||||||
await loadAndFly(page, DEST, HUB);
|
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<string[]> {
|
||||||
|
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<Array<[number, number]>> = 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<number>(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 position 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. `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<string, unknown>; geometry?: { coordinates: number[] } }>) {
|
||||||
|
if (f.properties?.id === 'plane-starter' && f.geometry) {
|
||||||
|
return { lat: f.geometry.coordinates[1]!, lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// map/source not ready yet — null keeps the caller polling
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. 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 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
|
||||||
|
// 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[] = [];
|
||||||
|
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 || !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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => 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
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -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.
|
// 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;
|
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||||||
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
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
|
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.
|
// Let leg 2 finish so the next test starts from a known airport.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
||||||
|
|
||||||
|
const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
||||||
|
id,
|
||||||
|
originIcao: 'LEMD',
|
||||||
|
originName: 'Madrid–Barajas',
|
||||||
|
destIcao: 'EHAM',
|
||||||
|
destName: 'Amsterdam Schiphol',
|
||||||
|
reward: 1000,
|
||||||
|
cargoType: 'mail',
|
||||||
|
cargoCount: 3,
|
||||||
|
weightKg: 900,
|
||||||
|
special: null,
|
||||||
|
distanceKm: 2000,
|
||||||
|
deadlineSimTime: 1_000_000,
|
||||||
|
status,
|
||||||
|
planeId: null,
|
||||||
|
createdSimTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||||
|
account: { cash: 20_000, reputation: 1 },
|
||||||
|
simState: { clockMs: 42 },
|
||||||
|
catalog: [],
|
||||||
|
fuelPricePerL: 0.8,
|
||||||
|
sellFraction: 0.5,
|
||||||
|
planes: [],
|
||||||
|
packages,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sseFrame', () => {
|
||||||
|
it('keeps exactly the open/loaded/expired packages and drops terminal history', () => {
|
||||||
|
const all = [
|
||||||
|
pkg('open', 'p-open'),
|
||||||
|
pkg('delivered', 'p-delivered'),
|
||||||
|
pkg('loaded', 'p-loaded'),
|
||||||
|
pkg('abandoned', 'p-abandoned'),
|
||||||
|
pkg('expired', 'p-expired'),
|
||||||
|
pkg('ignored', 'p-ignored'),
|
||||||
|
];
|
||||||
|
const frame = sseFrame(snapshot(all));
|
||||||
|
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the rest of the snapshot through untouched and does not mutate its input', () => {
|
||||||
|
const all = [pkg('open', 'a'), pkg('delivered', 'b')];
|
||||||
|
const snap = snapshot(all);
|
||||||
|
const frame = sseFrame(snap);
|
||||||
|
expect(frame.account).toEqual({ cash: 20_000, reputation: 1 });
|
||||||
|
expect(frame.simState).toEqual({ clockMs: 42 });
|
||||||
|
expect(frame.planes).toEqual([]);
|
||||||
|
expect(all.map((p) => p.status)).toContain('delivered'); // input intact
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE_PACKAGE_STATUSES matches what the client renders (open market, on-board cargo)', () => {
|
||||||
|
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,16 @@ export interface StateSnapshot {
|
|||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
|
||||||
|
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
|
||||||
|
// Terminal history (delivered/abandoned/ignored) is unbounded in the DB and never read by the
|
||||||
|
// client, so it stays out of every pushed frame; /api/state and action responses keep the full set.
|
||||||
|
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
|
||||||
|
|
||||||
|
export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
|
||||||
|
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
||||||
|
|||||||
+147
-1
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm } from './geo';
|
||||||
|
import { loadAirportsGeoJson } from './airports';
|
||||||
|
import { positionAt } from './sim/movement';
|
||||||
|
import { PLANE_TYPES } from './game/catalog';
|
||||||
|
|
||||||
const AIRPORTS = new Map<string, AirportCoord>([
|
const AIRPORTS = new Map<string, AirportCoord>([
|
||||||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||||||
@@ -45,7 +48,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
|
|
||||||
describe('planesGeoJson', () => {
|
describe('planesGeoJson', () => {
|
||||||
it('returns an empty collection for a null snapshot', () => {
|
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', () => {
|
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
||||||
@@ -56,6 +59,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: [] },
|
{ 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);
|
expect(fc.features).toHaveLength(2);
|
||||||
@@ -82,6 +86,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: [] },
|
{ 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');
|
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||||||
});
|
});
|
||||||
@@ -93,11 +98,116 @@ 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: [] },
|
{ 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('');
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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)', () => {
|
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
|
||||||
it('returns an empty collection for a null snapshot', () => {
|
it('returns an empty collection for a null snapshot', () => {
|
||||||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
@@ -396,3 +506,39 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
|||||||
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
|
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<string, AirportCoord>([
|
||||||
|
['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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+24
-5
@@ -41,15 +41,30 @@ export interface FeatureCollection<T> {
|
|||||||
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
||||||
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
||||||
|
|
||||||
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
// 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<string, AirportCoord>,
|
||||||
|
): FeatureCollection<MapPointFeature> {
|
||||||
if (!state) return EMPTY_POINTS;
|
if (!state) return EMPTY_POINTS;
|
||||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
// 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 labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||||
const flying = p.status === 'flying';
|
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 {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
geometry: { type: 'Point', coordinates: [lon, p.lat] },
|
||||||
properties: {
|
properties: {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
modelLabel: labelByModel.get(p.model) ?? p.model,
|
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
|
// 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;
|
// 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).
|
// 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][] {
|
function unwrapLons(coords: [number, number][]): [number, number][] {
|
||||||
let prev = coords[0]![0];
|
let prev = coords[0]![0];
|
||||||
for (let i = 1; i < coords.length; i++) {
|
for (let i = 1; i < coords.length; i++) {
|
||||||
let lon = coords[i]![0];
|
const lon = unwrapLon(coords[i]![0], prev);
|
||||||
while (lon - prev > 180) lon -= 360;
|
|
||||||
while (prev - lon > 180) lon += 360;
|
|
||||||
coords[i]![0] = lon;
|
coords[i]![0] = lon;
|
||||||
prev = lon;
|
prev = lon;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -7,7 +7,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
import { SPECIALS } from '@/lib/game/specials';
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { getStateSnapshot, parseIcaoQueue } from '@/lib/game/state';
|
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
|
||||||
import { broadcast } from '@/lib/sse';
|
import { broadcast } from '@/lib/sse';
|
||||||
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import {
|
import {
|
||||||
@@ -420,12 +420,13 @@ export async function spawnCoveragePackages(
|
|||||||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||||||
|
|
||||||
// ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE
|
// ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE
|
||||||
// subscribers (/api/events) — the exact same body GET /api/state returns (getStateSnapshot), so
|
// subscribers (/api/events). ISS-133 (v0.59.0): frames carry the sseFrame projection — terminal
|
||||||
// pushed and polled states can never diverge in shape. Failures are logged, not fatal: polling
|
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
||||||
// still works either way. Background ticks (~1 s) cover action mutations for other clients too.
|
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||||
|
// other clients too.
|
||||||
async function broadcastState(): Promise<void> {
|
async function broadcastState(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
broadcast(JSON.stringify(await getStateSnapshot()));
|
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('flight: state broadcast failed', err);
|
console.error('flight: state broadcast failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -106,7 +106,8 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
|||||||
// malformed event — the next one overwrites it anyway
|
// malformed event — the next one overwrites it anyway
|
||||||
}
|
}
|
||||||
stopPolling(); // fresh push flow → polling no longer needed
|
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
|
source.onerror = () => startPolling(); // stream trouble → poll until pushes resume
|
||||||
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll
|
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.57.0",
|
"version": "0.59.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user