Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f352adcf | ||
|
|
2b5c8541d7 | ||
|
|
673365f49c | ||
|
|
bb4e3b96f3 | ||
|
|
e970f95036 | ||
|
|
03731b938e | ||
|
|
1d9e9ade58 | ||
|
|
6a819b8d5a | ||
|
|
c5413aff65 | ||
|
|
44996b434f | ||
|
|
d0d764e0c1 | ||
|
|
d0aec35db1 | ||
|
|
7671c028b4 | ||
|
|
baa4b0fa7c | ||
|
|
c06e038130 | ||
|
|
18e5c832e3 | ||
|
|
1983d17099 | ||
|
|
9b2938a5b0 | ||
|
|
0a40143497 | ||
|
|
5268a39fe9 | ||
|
|
ae91bf6a5d | ||
|
|
a8c4e93dc1 | ||
|
|
e5693b3301 | ||
|
|
dae354410f | ||
|
|
d53ad41c09 | ||
|
|
82061ed869 | ||
|
|
2a4b341a9c |
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## v0.63.0 — 2026-09-23 08:43
|
||||
- **Overlay colors retuned for the Fiord default (ISS-118).** The first half of this issue landed in v0.4.0 (`DEFAULT_STYLE_ID = 'fiord'` + top-bar switcher, e2e already pins `toHaveValue('fiord')`); this completes the second half — auditing every color drawn on the map against Fiord's actual palette. Key finding: OpenFreeMap's Fiord is a DARK cool-toned basemap (land #45516E slate, water #38435C navy — verified byte-identical between the self-hosted `public/styles/fiord.json` and `tiles.openfreemap.org/styles/fiord`), not a light one. WCAG contrast audit of all nine overlay colors against both base surfaces: seven already read fine (≥ 3.3:1 on land AND water) and are untouched — amber flying leg #f59e0b, gold dotted queue line #d4a017 (ISS-098 picked it for light+dark), sky-blue grounded plane + hover halo #38bdf8, green flying plane #22c55e, yellow hover ring/trace #facc15, green target ring #4ade80. Two were weak and got brightened one step within the same hue family: airport dot fill rose-600 #e11d48 (1.68:1 on slate land — the weakest element on the map) → rose-500 #f43f5e (2.15:1), and the hovered-route highlight red-500 #ef4444 (2.10:1, below the 3:1 graphical-object bar for an emphasis layer) → red-400 #f87171 (2.86:1 on land / 3.57:1 on water). White strokes and all other paint values unchanged; no new constants — both colors are single-use inline paint values, matching the existing style of this file.
|
||||
- **Tests.** No test changes needed: `components/map.test.ts` pins only #d4a017 (untouched), e2e specs query rendered features by id/layer rather than color, and `lib/mapStyles.test.ts` already pins `DEFAULT_STYLE_ID = 'fiord'`.
|
||||
|
||||
## v0.62.0 — 2026-09-23 07:36
|
||||
- **Fixed 24x world clock (#135).** Finalized the closed issue #117 spec: the world clock runs at the fixed `WORLD_TIME_SCALE = 24` while any plane is flying or in repair, and stays FROZEN when the whole fleet is grounded — replacing v0.56.0's idle slowdown (`IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24`, ISS-128). **Production rate is now exactly 24x regardless of legacy stored values:** `tickOnce` in `lib/sim/engine.ts` computes `advanceMs = advance ? min(elapsed, MAX_TICK_ELAPSED_MS) × scale : 0` where `scale = FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE` — pre-v0.52.0 saves hold `timeScale: 1000` (the old UI ladder), which is why the live instance still ran at ×1000; only deployments with the env var set honor the stored override written by `POST /api/test/set-timescale` (range [1, 100_000], kept for tests). A frozen tick skips expiry/delivery/repair/spawn entirely and only updates `lastRealTickMs`, so resuming activity never fast-forwards the frozen span. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) holds its anchor value while grounded, so polls re-anchor without any jump. Removed: `IDLE_WORLD_TIME_SCALE` from `lib/worldClock.ts`, and — **separate finding** — `FLIGHT_TEST_HOOKS: "1"` from the production `docker-compose.yml`: it enabled the `/api/test/*` hooks in prod AND (pre-gate) made the live instance honor its stale stored 1000; e2e runs only in CI against `docker-compose.ci.yml`, which sets the var itself. Unchanged on purpose: the ~1 s background tick (started at boot), the test hook endpoint, and the inert legacy columns `paused`/`lastSelectedTimeScale` (nothing reads them; no migration — per #117 saves reset when multiplayer ships). The OpenAPI description of `/api/state` ("while no plane is flying or in repair the clock stays frozen") now matches the code.
|
||||
- **Tests.** Unit: `lib/sim/engine.test.ts` — both rate branches pinned: with `FLIGHT_TEST_HOOKS=1` stubbed (the file's default, like CI) stored overrides are honored; a new production test unsets the var and proves a legacy stored `timeScale: 1000` still advances at exactly ×24. Grounded-fleet tests pin a FROZEN clock (no idle creep, resume-without-catch-up after takeoff, raised timeScale ignored while grounded); describe renamed to "grounded fleet freezes the world clock (#135)" and pins `WORLD_TIME_SCALE = 24`. `lib/useSimClock.test.ts` — the grounded case asserts staying exactly at the anchor value. E2E: `e2e/sim.spec.ts`'s idle test becomes an exact freeze (delta === 0 after ~2.5 s of idleness); `e2e/state.spec.ts`'s restart test tightens from bounded delta to EXACT clock equality across `docker compose restart`; `e2e/right-pane.spec.ts` verifies the frozen clock, then asserts the abandon-penalty preview matches the server exactly (zero drift under a frozen clock — replaces the DRIFT_MS slope tolerance); `e2e/events.spec.ts` relaxes strict clock advance between SSE frames to non-decreasing (a grounded fleet pushes identical-clockMs frames; advance itself is pinned in sim.spec.ts). The activateWorld/deactivateWorld wrappers are unchanged — a repairing plane still means full scale.
|
||||
|
||||
## v0.61.0 — 2026-09-23 07:12
|
||||
- **Special remote airports (ISS-097).** The world dataset was curated to `large_airport` only, so genuinely empty regions — Antarctica, the high Arctic, Alaska, northern Canada and far islands — had no markers at all. New pure function `isRemote(airports, icao)` in `lib/airports.ts` (reusing the existing `haversineKm` from `lib/geo.ts`): an airport is remote when no other member of the given list lies within `REMOTE_RADIUS_KM = 1000`. The curation in `scripts/build-world-airports.mjs` now runs over ALL fixed-wing rows: every large_airport (unchanged clustering) plus every non-large row that is remote against the kept set; same-station twins (two strips <40 km apart at one base) collapse via the same greedy 40 km clustering. `data/airports_world.csv` grows from 1119 → **1483 rows** (+364 remote airports), including Rothera (EGAR), Halley (EGAH) and Showa skiway (AT25) in Antarctica; Grise Fiord (CYGZ), Alert (CYLT), Ny-Ålesund (ENAS — Svalbard's only marker within 1000 km of a large airport) and Wrangel Island (UHMZ) in the Arctic; Bethel (PABR) and other Alaska strips; Canadian-Arctic smalls; and far islands such as Easter (SCIP), Canton (PCIS), Henderson/Pago Pago (PMDY) and Ascension (FHAW). The only change to a pre-existing row is an upstream `home_link` refresh on CYLW. New airports become contract origins/destinations and map markers unchanged — the most isolated airport in the new set (Easter Island) has its nearest neighbour 2599 km away, well under the 9000 km job cap, so every origin stays flyable; `filterWorldAirports` already accepts small/medium/seaplane types, so no runtime change was needed.
|
||||
- **Tests.** Unit +4 in `lib/airports.test.ts`: a synthetic radius boundary (equator pair at ~945 km vs ~1052 km), real coordinates (Frankfurt↔Amsterdam is not remote; McMurdo vs Christchurch is), an unknown ICAO returning false, and the committed-dataset curation invariant — every non-large row has no large airport within 1000 km and at least one non-large row exists.
|
||||
|
||||
## 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
|
||||
- **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.
|
||||
- **Tests.** Unit: new `lib/sse.test.ts` — subscribe/broadcast/unsubscribe semantics, a throwing subscriber doesn't break others, idempotent unsubscribe; new `app/api/events/route.test.ts` (throwaway-DB pattern from the state route test) — SSE headers + first chunk parses as a snapshot with `simState.clockMs`. E2E: new `e2e/events.spec.ts` (+1) — node-side fetch of `/api/events` against the app under test asserts content-type `text/event-stream`, the first event parses as a valid snapshot (`simState.clockMs`), and within ~5 s at least one MORE event arrives with an advanced clock (background tick pushes while idle since v0.56.0 — no scale hook); stream closed in `finally`. The exact-endpoint-list assertions in `lib/openapi.test.ts` and `e2e/api.spec.ts` move twelve → thirteen endpoints (`/api/events` added).
|
||||
|
||||
## v0.56.0 — 2026-09-22 18:28
|
||||
- **Idle fleet slows the world to real time instead of freezing it (ISS-128).** A fully grounded fleet no longer freezes the sim clock — `tickOnce` in `lib/sim/engine.ts` now advances by `min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE` when nothing is flying or in repair, where new constant `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24` (= exactly 1, i.e. real time) lives next to `WORLD_TIME_SCALE` in `lib/worldClock.ts`. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and `lastRealTickMs` still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump.
|
||||
- **Tests.** Unit: `lib/sim/engine.test.ts` — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1`. `lib/useSimClock.test.ts` — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: `e2e/sim.spec.ts`'s freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); `e2e/state.spec.ts`'s restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ `MAX_TICK_ELAPSED_MS` of clamped downtime + the live wall span; `e2e/right-pane.spec.ts`'s penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The `activateWorld`/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated.
|
||||
|
||||
## v0.55.0 — 2026-09-22 15:38
|
||||
- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `<link rel="icon">` into the head; no layout/metadata changes needed.
|
||||
- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset.
|
||||
- **E2E hardening (CI run #167).** Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon `<link href>` (`/icon.svg?icon.<hash>.svg` — matched with `/^\/icon\.svg/`, canonical `GET /icon.svg` check unchanged), and `e2e/hover.spec.ts`'s local `flyHomeViaApi` self-heal is replaced by the established `e2e/panes.spec.ts` pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked `locationIcao` against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin").
|
||||
- **E2E hardening (CI run #168).** Retry-cascade fix in `e2e/actions.spec.ts`: its local `flyHomeViaApi` self-heal now also tops plane-starter's tank back to full via the free `POST /api/test/set-fuel` hook after repositioning home. A transient `/api/state` 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0.
|
||||
- **E2E hardening (ISS-127).** e2e `getState` hardened against transient `/api/state` 500s — internal retry ×3/500 ms keeps poll callbacks total.
|
||||
|
||||
## v0.54.0 — 2026-09-22 15:20
|
||||
- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
|
||||
- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.
|
||||
|
||||
## v0.53.0 — 2026-09-22 14:47
|
||||
- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
|
||||
- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { GET } from './route';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
|
||||
// this test never touches ./db/flight.db. getGameDb() reads the env var on first call, and
|
||||
// vitest gives each test file its own module registry (fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
it('streams SSE with the current snapshot as the first event', async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
expect(res.headers.get('cache-control')).toBe('no-cache');
|
||||
expect(res.headers.get('x-accel-buffering')).toBe('no');
|
||||
|
||||
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
||||
try {
|
||||
const { value } = await reader.read(); // first chunk: the initial snapshot event
|
||||
const text = new TextDecoder().decode(value);
|
||||
expect(text.startsWith('data: ')).toBe(true);
|
||||
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
||||
} finally {
|
||||
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
||||
import { subscribe } from '@/lib/sse';
|
||||
|
||||
// ISS-129 (v0.57.0): Server-Sent Events feed of state snapshots — the current snapshot on connect,
|
||||
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
|
||||
// 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 HEARTBEAT_MS = 15_000;
|
||||
|
||||
export async function GET() {
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
const cleanup = () => {
|
||||
if (heartbeat !== null) clearInterval(heartbeat);
|
||||
if (unsubscribe !== null) unsubscribe();
|
||||
};
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
||||
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
|
||||
// for the next push instead of seeing an out-of-order frame.
|
||||
getStateSnapshot()
|
||||
.then((snapshot) => {
|
||||
unsubscribe = subscribe((text) => {
|
||||
try {
|
||||
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
||||
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
||||
controller.enqueue(encoder.encode(`data: ${text}\n\n`));
|
||||
} catch {
|
||||
// stream already closed — cleanup runs on cancel
|
||||
}
|
||||
});
|
||||
heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(': ping\n\n'));
|
||||
} catch {
|
||||
// same as above
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(sseFrame(snapshot))}\n\n`));
|
||||
})
|
||||
.catch(() => {
|
||||
cleanup();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
cleanup(); // client went away — drop the subscription + heartbeat
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // prod sits behind Traefik — don't buffer the stream
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#93c5fd"/>
|
||||
<path d="M32 5 L36 20 L59 33 L36 38 L35 47 L48 56 L35 52 L32 59 L29 52 L16 56 L29 47 L28 38 L5 33 L28 20 Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
+7
-4
@@ -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));
|
||||
@@ -358,7 +360,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
source: AIRPORTS_SOURCE_ID,
|
||||
paint: {
|
||||
'circle-radius': 5, // ISS-010 (v0.18.0): bigger — the r=3 dots were hard to click
|
||||
'circle-color': '#e11d48',
|
||||
'circle-color': '#f43f5e', // ISS-118 (v0.60.0): rose-500 — old #e11d48 (rose-600) was tuned for light tiles; 1.7:1 on Fiord's dark slate land
|
||||
'circle-stroke-width': 1.5,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
},
|
||||
@@ -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({
|
||||
@@ -459,7 +462,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
source: ROUTES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'planeId'], ''],
|
||||
paint: {
|
||||
'line-color': '#ef4444', // red — distinct from the amber routes-legs line
|
||||
'line-color': '#f87171', // ISS-118 (v0.60.0): red-400 — old #ef4444 sat at 2.1:1 on Fiord's dark slate; brighter so the hover emphasis reads, still distinct from the amber routes-legs line
|
||||
'line-width': 6,
|
||||
},
|
||||
});
|
||||
|
||||
+365
-1
@@ -11,7 +11,7 @@
|
||||
"610368","CA-1292","large_airport","YEG","50.341652","-106.699219","2373","NA","CA","CA-AB","Edmonton","yes","","","","","","",""
|
||||
"1763","CYHM","large_airport","John C. Munro Hamilton International Airport","43.171308","-79.929416","780","NA","CA","CA-ON","Hamilton","yes","CYHM","YHM","CYHM","","https://www.flyhi.ca/","https://en.wikipedia.org/wiki/Hamilton/John_C._Munro_International_Airport","Mount Hope Airport"
|
||||
"1770","CYHZ","large_airport","Halifax / Stanfield International Airport","44.8807983398","-63.5085983276","477","NA","CA","CA-NS","Halifax","yes","CYHZ","YHZ","CYHZ","","http://www.hiaa.ca/","https://en.wikipedia.org/wiki/Halifax_International_Airport","Robert L. Stanfield International Airport"
|
||||
"1808","CYLW","large_airport","Kelowna International Airport","49.9561","-119.377998","1421","NA","CA","CA-BC","Kelowna","yes","CYLW","YLW","CYLW","","http://www.kelowna.ca/CM/Page68.aspx","https://en.wikipedia.org/wiki/Kelowna_International_Airport","Kelowna, Okanagan"
|
||||
"1808","CYLW","large_airport","Kelowna International Airport","49.9561","-119.377998","1421","NA","CA","CA-BC","Kelowna","yes","CYLW","YLW","CYLW","","https://ylw.kelowna.ca/","https://en.wikipedia.org/wiki/Kelowna_International_Airport","Kelowna, Okanagan"
|
||||
"1840","CYOW","large_airport","Ottawa Macdonald-Cartier International Airport","45.322498","-75.669197","374","NA","CA","CA-ON","Ottawa","yes","CYOW","YOW","CYOW","YOW","https://yow.ca/","https://en.wikipedia.org/wiki/Ottawa_Macdonald%E2%80%93Cartier_International_Airport","Uplands, UUP, CUUP"
|
||||
"1864","CYQB","large_airport","Quebec Jean Lesage International Airport","46.7911","-71.393303","244","NA","CA","CA-QC","Quebec","yes","CYQB","YQB","CYQB","YQB","http://www.aeroportdequebec.com/","https://en.wikipedia.org/wiki/Qu%C3%A9bec/Jean_Lesage_International_Airport","Quebec City"
|
||||
"1867","CYQG","large_airport","Windsor International Airport","42.2756","-82.955597","622","NA","CA","CA-ON","Windsor","yes","CYQG","YQG","CYQG","","https://flyyqg.ca/","https://en.wikipedia.org/wiki/Windsor_Airport",""
|
||||
@@ -1118,3 +1118,367 @@
|
||||
"27241","ZYQQ","large_airport","Qiqihar Sanjiazi Airport","47.229969","123.914179","477","AS","CN","CN-23","Qiqihar","yes","ZYQQ","NDG","ZYQQ","","","https://en.wikipedia.org/wiki/Qiqihar_Airport","齐齐哈尔, 齐齐哈尔三家子机场"
|
||||
"27242","ZYTL","large_airport","Dalian Zhoushuizi International Airport","38.965719","121.538477","107","AS","CN","CN-21","Dalian (Ganjingzi)","yes","ZYTL","DLC","ZYTL","","https://dlairport.com","https://en.wikipedia.org/wiki/Dalian_Zhoushuizi_International_Airport","Dalian Air Base"
|
||||
"27243","ZYTX","large_airport","Shenyang Taoxian International Airport","41.6398","123.483668","198","AS","CN","CN-21","Shenyang","yes","ZYTX","SHE","ZYTX","","","https://en.wikipedia.org/wiki/Shenyang_Taoxian_International_Airport","沈阳, 沈阳桃仙国际机场"
|
||||
"16155","AK62","seaplane_base","Naukati Bay Seaplane Base","55.849602","-133.227994","","NA","US","US-AK","Tuxekan Island","yes","","NKI","AK62","AK62","","https://en.wikipedia.org/wiki/Naukati_Bay_Seaplane_Base","62A, Nichin Cove Seaplane Base, WNC"
|
||||
"8159","19P","seaplane_base","Port Protection Seaplane Base","56.3288","-133.610001","","NA","US","US-AK","Port Protection","yes","","PPV","19P","19P","","https://en.wikipedia.org/wiki/Port_Protection_Seaplane_Base","AK19"
|
||||
"9296","2AK8","small_airport","Icy Cape Air Force Station Airport","70.293545","-161.907151","48","NA","US","US-AK","Icy Cape","no","","","2AK8","2AK8","","",""
|
||||
"9902","2Z1","seaplane_base","Entrance Island Seaplane Base","57.412201","-133.43848","0","NA","US","US-AK","Entrance Island","no","","HBH","","","","","2Z1"
|
||||
"5398","PAPG","medium_airport","Petersburg James A Johnson Airport","56.80170059","-132.9450073","111","NA","US","US-AK","Petersburg","yes","PAPG","PSG","PAPG","PSG","","https://en.wikipedia.org/wiki/Petersburg_James_A._Johnson_Airport",""
|
||||
"5421","PAWG","medium_airport","Wrangell Airport","56.48429871","-132.3699951","49","NA","US","US-AK","Wrangell","yes","PAWG","WRG","PAWG","WRG","","https://en.wikipedia.org/wiki/Wrangell_Airport",""
|
||||
"19240","KAE","seaplane_base","Kake Seaplane Base","56.973","-133.945999","","NA","US","US-AK","Kake","yes","","KAE","","KAE","","https://en.wikipedia.org/wiki/Kake_Seaplane_Base",""
|
||||
"14860","PABU","small_airport","Bullen Point Short Range Radar Station Airport","70.170999","-146.844897","18","NA","US","US-AK","Kaktovik","no","","","PABU","8AK7","","https://en.wikipedia.org/wiki/Bullen_Point_Short_Range_Radar_Site",""
|
||||
"307366","EGAR","small_airport","Rothera Research Station","-67.5675","-68.127403","9","AN","AQ","AQ-U-A","Rothera Research Station","no","EGAR","","EGAR","","http://www.antarctica.ac.uk/living_and_working/research_stations/rothera/","https://en.wikipedia.org/wiki/Rothera_Research_Station",""
|
||||
"5405","PASC","medium_airport","Deadhorse Airport","70.19470215","-148.4649963","65","NA","US","US-AK","Deadhorse","yes","PASC","SCC","PASC","SCC","","https://en.wikipedia.org/wiki/Deadhorse_Airport",""
|
||||
"5401","PAQT","medium_airport","Nuiqsut Airport","70.209999","-151.005998","38","NA","US","US-AK","Nuiqsut","yes","PAQT","NUI","PAQT","AQT","","https://en.wikipedia.org/wiki/Nuiqsut_Airport","10AK"
|
||||
"312361","ANZ","small_airport","Angus Downs Airport","-25.0325","132.2748","1724","OC","AU","AU-NT","Angus Downs Station","no","","ANZ","YADN","","","",""
|
||||
"39579","AQ-0001","small_airport","Novolazarevskaya Station","-70.8461","11.8472","390","AN","AQ","AQ-U-A","Queen Maud Land","no","","","AT17","","","https://en.wikipedia.org/wiki/Novolazarevskaya_Station",""
|
||||
"42221","AQ-0002","small_airport","Troll Airfield","-71.95714","2.45257","4167","AN","AQ","AQ-U-A","Troll Station","no","","","ENOE","AT27","","https://en.wikipedia.org/wiki/Troll_airfield",""
|
||||
"336951","AQBC","medium_airport","Boulder Clay Runway","-74.739373","164.03539","672","AN","AQ","AQ-U-A","Zucchelli Station","no","","","AQBC","","","https://en.wikipedia.org/wiki/Zucchelli_Station",""
|
||||
"345862","AQ-0011","small_airport","Wolfs Fang Runway","-71.545026","8.798389","3707","AN","AQ","AQ-U-A","","no","","","","","https://white-desert.com/about-us/wolfsfang-runway-logistics/","https://en.wikipedia.org/wiki/Wolfs_Fang_Runway","AT98, Henrickson Nunatak, Queen Maud Land"
|
||||
"5054","NZSP","medium_airport","Amundsen–Scott South Pole Station Airport","-90","-1","9300","AN","AQ","AQ-U-A","Amundsen-Scott South Pole Station","no","NZSP","","NZSP","","","https://en.wikipedia.org/wiki/Amundsen-Scott_South_Pole_Station",""
|
||||
"603000","AQ-0016","medium_airport","Grandfather Skiway","-77.530242","-31.827393","","AN","AQ","AQ-U-A","Antartica","no","","","","","","",""
|
||||
"336953","AT03","medium_airport","Concordia Skyway","-75.103278","123.35825","10725","AN","AQ","AQ-U-A","Concordia Station","no","","","AT03","","","https://en.wikipedia.org/wiki/Concordia_Station",""
|
||||
"330063","AT25","small_airport","Showa Station Skiway","-69.006167","39.59","95","AN","AQ","AQ-U-A","Showa Station","no","","","AT25","","","https://en.wikipedia.org/wiki/Showa_Station_(Antarctica)","Syowa Station"
|
||||
"336959","AT28","medium_airport","Vostok Skiway","-78.466139","106.84825","11447","AN","AQ","AQ-U-A","Vostok Station","no","","","AT28","","","https://en.wikipedia.org/wiki/Vostok_Station",""
|
||||
"333856","AU-0112","small_airport","Aileron Airstrip","-22.652908","133.347244","2186","OC","AU","AU-NT","Anmatjere","no","","","YALR","","","",""
|
||||
"351706","AU-0272","small_airport","Planet Downs OS","-25.872662","141.116788","","OC","AU","AU-QLD","Planet downs OS","no","","","","","","",""
|
||||
"352795","AU-0345","small_airport","Hamilton Downs Airstrip","-23.507335","133.260577","","OC","AU","AU-NT","Burt Plain","no","","","","","","",""
|
||||
"27846","YNPB","small_airport","Napperby Airport","-22.5312","132.7632","2135","OC","AU","AU-NT","Napperby","no","","NPP","YNPB","","","",""
|
||||
"27793","YMNY","small_airport","Morney Airport","-25.358299255371094","141.43299865722656","104","OC","AU","AU-QLD","","no","YMNY","OXY","YMNY","","","",""
|
||||
"507903","AU-0571","small_airport","Lake MacKay Airstrip","-23.670782","129.976158","","OC","AU","AU-NT","Lake MacKay","no","","","","","","",""
|
||||
"508151","AU-0586","small_airport","homestead","-23.317075","138.587637","","OC","AU","AU-NT","Sturt QLD 4829","no","","","","","","",""
|
||||
"38379","YHBY","small_airport","Henbury Airport","-24.584","133.236","","OC","AU","AU-NT","Ghan","no","YHBY","HRY","YHBY","","","",""
|
||||
"31745","AU-KGR","small_airport","Kulgera Airport","-25.8428","133.292007","","OC","AU","AU-NT","Ghan","no","","KGR","","","","",""
|
||||
"44051","BGDH","small_airport","Danmarkshavn Landing Strip","76.76667","-18.666668","","NA","GL","GL-NE","Danmarkshavn","no","","","BGDH","","","",""
|
||||
"299843","BGMI","small_airport","Station Nord Landing Strip","81.601292","-16.679306","35","NA","GL","GL-NE","Station Nord","no","","","BGMI","","","https://en.wikipedia.org/wiki/Nord,_Greenland",""
|
||||
"32004","BGQQ","medium_airport","Qaanaaq Airport","77.488602","-69.388702","51","NA","GL","GL-AV","Qaanaaq","yes","BGQQ","NAQ","BGQQ","","","https://en.wikipedia.org/wiki/Qaanaaq_Airport","Mittarfik Qaanaaq, Thule"
|
||||
"116","BGTL","medium_airport","Pituffik Space Base","76.53063","-68.700541","251","NA","GL","GL-AV","Pituffik","no","BGTL","THU","BGTL","","","https://en.wikipedia.org/wiki/Pituffik_Space_Base","Thule Air Base"
|
||||
"874","CEM3","small_airport","Whatì Airport","63.131699","-117.246002","882","NA","CA","CA-NT","Whatì","yes","","YLE","CEM3","CEM3","","https://en.wikipedia.org/wiki/What%C3%AC_Airport","EM3, Lac la Martre, Wha Ti"
|
||||
"299911","CA-0504","small_airport","Mary River Aerodrome","71.324167","-79.356944","584","NA","CA","CA-NU","Mary River","no","","YMV","","CMR2","","https://en.wikipedia.org/wiki/Mary_River_Aerodrome",""
|
||||
"316098","CA-0551","seaplane_base","Cameron Bay Water Aerodrome","66.06","-117.89","512","NA","CA","CA-NT","Cameron Bay","no","","","","EA7","","",""
|
||||
"316114","CA-0554","seaplane_base","Clarence Lagoon Water Aerodrome","69.62","-140.81","0","NA","CA","CA-YT","","no","","","","EC7","","",""
|
||||
"316197","CA-0556","seaplane_base","Boffa Lake Water Aerodrome","69.637","-116.196","338","NA","CA","CA-NU","","no","","","","EX6","","",""
|
||||
"342506","CA-0737","small_airport","Seal River Airstrip","59.154598","-94.785554","20","NA","CA","CA-MB","Churchill","no","","","","","","",""
|
||||
"342507","CA-0738","small_airport","Nester One Airstrip","58.6529","-93.19061","26","NA","CA","CA-MB","Churchill","no","","","","","","",""
|
||||
"723","CBU2","small_airport","Eddontenajon / Iskut Village Airport","57.847994155","-129.983968735","3100","NA","CA","CA-BC","Eddontenajon","no","","","CBU2","CBU2","","https://en.wikipedia.org/wiki/Eddontenajon/Iskut_Village_Airport","BU2"
|
||||
"349351","CA-0771","small_airport","Arctic Watch Lodge Airport","74.065757","-93.785965","","NA","CA","CA-NU","Somerset Island","no","","","CRW4","RW4","","https://en.wikipedia.org/wiki/Arctic_Watch_Lodge_Aerodrome",""
|
||||
"430490","CA-0815","small_airport","Nuskenford Airstrip","69.863861","-126.843167","","NA","CA","CA-NT","Nuskenford","no","","","","","","",""
|
||||
"524817","CA-0924","seaplane_base","Herschel Island Water Aerodrome","69.572","-138.93","0","NA","CA","CA-YT","Herschel","no","","","","EG8","","",""
|
||||
"595702","CA-1179","small_airport","Cassiar Aerodrome","59.277137","-129.805044","3618","NA","CA","CA-BC","Cassiar","no","","","","AU9","","",""
|
||||
"606385","CA-1249","small_airport","Fort Conger Airstrip","81.754925","-64.824958","15","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1249","","","FCON"
|
||||
"606387","CA-1250","small_airport","Lake Hazen Airstrip","81.823025","-71.360321","10","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1250","","","LHAZ"
|
||||
"606493","CA-1251","small_airport","McGill Arctic Research Station (MARS)","79.41596","-90.761018","","NA","CA","CA-NU","Axel Heiberg Island","no","","","","","","",""
|
||||
"607046","CA-1252","small_airport","Ward Hunt Island Airport","83.091029","-74.138317","","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1252","","https://en.wikipedia.org/wiki/Ward_Hunt_Island",""
|
||||
"609524","CA-1259","small_airport","Bathurst Inlet Airport","66.836865","-108.011278","36","NA","CA","CA-NU","Bathurst Inlet/Qingaut","no","","","","","","","ᕿᙵᐅᓐ"
|
||||
"28437","CAH9","seaplane_base","Telegraph Creek Seaplane Base","57.9071","-131.18946","1129","NA","CA","CA-BC","Telegraph Creek","no","","","CAH9","CAH9","","https://en.wikipedia.org/wiki/Telegraph_Creek_Water_Aerodrome","AH9"
|
||||
"665","CBC2","small_airport","Ford Bay Airport","66.0374984741211","-124.71499633789062","673","NA","CA","CA-NT","Ford Bay","no","","","CBC2","CBC2","","https://en.wikipedia.org/wiki/Ford_Bay_Airport","BC2"
|
||||
"672","CBD6","small_airport","Nahanni Butte Airport","61.029701232910156","-123.38899993896484","600","NA","CA","CA-NT","Nahanni Butte","no","","","CBD6","CBD6","","https://en.wikipedia.org/wiki/Nahanni_Butte_Airport","BD6"
|
||||
"686","CBH4","small_airport","Prairie Creek Airport","61.564701080322266","-124.81500244140625","2950","NA","CA","CA-NT","Prairie Creek","no","","","CBH4","CBH4","","https://en.wikipedia.org/wiki/Prairie_Creek_Airport","BH4"
|
||||
"736","CBX5","small_airport","Tungsten (Cantung) Airport","61.956901550293","-128.20300292969","3500","NA","CA","CA-NT","Tungsten","no","","TNS","CBX5","CBX5","","https://en.wikipedia.org/wiki/Tungsten_(Cantung)_Airport","BX5"
|
||||
"814","CDK2","small_airport","Diavik Airport","64.5113983154","-110.289001465","1413","NA","CA","CA-NT","Diavik","no","","DVK","CDK2","CDK2","","https://en.wikipedia.org/wiki/Diavik_Airport","DK2"
|
||||
"43879","CDL7","small_airport","Doris Lake","68.1252746582","-106.585281372","50","NA","CA","CA-NU","Hope Bay","no","","JOJ","","CDL7","","https://en.wikipedia.org/wiki/Doris_Lake_Aerodrome",""
|
||||
"1802","CYLK","small_airport","Lutselk'e Airport","62.417823","-110.682793","596","NA","CA","CA-NT","Lutselk'e","yes","CYLK","YSG","CYLK","","","https://en.wikipedia.org/wiki/Lutselk'e_Airport","YLK, EL3, Snowdrift Airport"
|
||||
"314953","CYVL","small_airport","Tommy Kochon Airport","67.02015","-126.128364","870","NA","CA","CA-NT","Colville Lake","yes","CYVL","YCK","CYVL","","","https://en.wikipedia.org/wiki/Colville_Lake/Tommy_Kochon_Aerodrome",""
|
||||
"925","CFA7","small_airport","Taltheilei Narrows Airport","62.5980987549","-111.542999268","570","NA","CA","CA-NT","Taltheilei Narrows","no","","GSL","CFA7","CFA7","","https://en.wikipedia.org/wiki/Taltheilei_Narrows_Airport","FA7"
|
||||
"1726","CYEV","medium_airport","Inuvik Mike Zubko Airport","68.30419921880001","-133.483001709","224","NA","CA","CA-NT","Inuvik","yes","CYEV","YEV","CYEV","","","https://en.wikipedia.org/wiki/Inuvik_(Mike_Zubko)_Airport",""
|
||||
"1868","CYQH","medium_airport","Watson Lake Airport","60.116839","-128.821993","2255","NA","CA","CA-YT","Watson Lake","yes","CYQH","YQH","CYQH","","","https://en.wikipedia.org/wiki/Watson_Lake_Airport",""
|
||||
"1948","CYWJ","small_airport","Déline Airport","65.211098","-123.435997","703","NA","CA","CA-NT","Déline","yes","CYWJ","YWJ","CYWJ","","","https://en.wikipedia.org/wiki/Deline_Airport","Délı̨nę, Fort Franklin"
|
||||
"1993","CYZF","medium_airport","Yellowknife International Airport","62.462799","-114.440002","675","NA","CA","CA-NT","Yellowknife","yes","CYZF","YZF","CYZF","","https://www.inf.gov.nt.ca/en/services/airports/yellowknife-airport","https://en.wikipedia.org/wiki/Yellowknife_Airport",""
|
||||
"28331","CEP9","seaplane_base","Namushka Lodge Seaplane Base","62.416698","-113.349998","670","NA","CA","CA-NT","","no","","","CEP9","CEP9","","https://en.wikipedia.org/wiki/Namushka_Lodge_Water_Aerodrome","EP9"
|
||||
"1786","CYKD","small_airport","Aklavik/Freddie Carmichael Airport","68.223297","-135.00599","23","NA","CA","CA-NT","Aklavik","yes","CYKD","LAK","CYKD","","","https://en.wikipedia.org/wiki/Aklavik_Airport","YKD"
|
||||
"28159","CES6","seaplane_base","Arctic Red River Seaplane Base","67.449997","-133.75","20","NA","CA","CA-NT","","no","","","","ES6","","https://en.wikipedia.org/wiki/Arctic_Red_River_Water_Aerodrome",""
|
||||
"944","CFF4","small_airport","Great Bear Lake Airport","66.703053","-119.716816","562","NA","CA","CA-NT","Great Bear Lake","no","","DAS","CFF4","CFF4","","https://en.wikipedia.org/wiki/Great_Bear_Lake_Airport","FF4"
|
||||
"1737","CYFS","medium_airport","Fort Simpson Airport","61.76020050048828","-121.23699951171875","555","NA","CA","CA-NT","Fort Simpson","yes","CYFS","YFS","CYFS","","","https://en.wikipedia.org/wiki/Fort_Simpson_Airport",""
|
||||
"901","CET9","small_airport","Jean Marie River Airport","61.522449","-120.625334","470","NA","CA","CA-NT","Jean Marie River","no","","","CET9","CET9","","https://en.wikipedia.org/wiki/Jean_Marie_River_Airport","ET9"
|
||||
"1940","CYVQ","medium_airport","Norman Wells Airport","65.28160095214844","-126.7979965209961","238","NA","CA","CA-NT","Norman Wells","yes","CYVQ","YVQ","CYVQ","","","https://en.wikipedia.org/wiki/Norman_Wells_Airport",""
|
||||
"909","CEV9","small_airport","Snare River Airport","63.4333","-116.174963","700","NA","CA","CA-NT","Snare River","no","","","CEV9","CEV9","","https://en.wikipedia.org/wiki/Snare_River_Airport","EV9"
|
||||
"1843","CYPC","small_airport","Paulatuk (Nora Aliqatchialuk Ruben) Airport","69.360838","-124.07547","15","NA","CA","CA-NT","Paulatuk","yes","CYPC","YPC","CYPC","","","https://en.wikipedia.org/wiki/Paulatuk_Airport",""
|
||||
"933","CFC4","small_airport","Macmillan Pass Airport","63.181098938","-130.20199585","3810","NA","CA","CA-YT","Macmillan Pass","no","","XMP","CFC4","CFC4","","https://en.wikipedia.org/wiki/Macmillian_Pass_Airport","FC4"
|
||||
"996","CFT3","small_airport","Finlayson Lake Airport","61.6913986206","-130.774002075","3094","NA","CA","CA-YT","Finlayson Lake","no","","","CFT3","CFT3","","https://en.wikipedia.org/wiki/Finlayson_Lake_Airport","FT3"
|
||||
"997","CFT5","small_airport","Hyland Airport","61.52389907836914","-128.2689971923828","3000","NA","CA","CA-YT","Hyland","no","","","CFT5","CFT5","","https://en.wikipedia.org/wiki/Hyland_Airport","FT5"
|
||||
"28459","CFV5","seaplane_base","Virginia Falls Seaplane Base","61.607575","-125.756272","1702","NA","CA","CA-NT","","no","","","CFV5","CFV5","","https://en.wikipedia.org/wiki/Virginia_Falls_Water_Aerodrome",""
|
||||
"1021","CFY5","small_airport","Pine Lake","60.10309982299805","-130.9340057373047","3250","NA","CA","CA-YT","Pine Lake","no","","","CFY5","CFY5","","https://en.wikipedia.org/wiki/Daughney_Aerodrome","FY5"
|
||||
"44373","CGK2","small_airport","Gahcho Kue","63.435198","-109.144812","1371","NA","CA","CA-NT","Kennedy Lake","no","","GHK","CGK2","CGK2","","https://en.wikipedia.org/wiki/Gahcho_Kue_Aerodrome","De Beers Canada"
|
||||
"308297","CGR3","small_airport","George Lake Aerodrome","65.928056","-107.462222","1150","NA","CA","CA-NU","","no","","","CGR3","CGR3","","https://en.wikipedia.org/wiki/George_Lake_Aerodrome",""
|
||||
"308296","CGS2","small_airport","Goose Lake Aerodrome","65.552778","-106.433611","907","NA","CA","CA-NU","","no","","","CGS2","CGS2","","https://en.wikipedia.org/wiki/Goose_Lake_Aerodrome",""
|
||||
"308295","CHC5","small_airport","Hayes Camp Aerodrome","66.661012","-91.55346","780","NA","CA","CA-NU","","no","","","CHC5","CHC5","","https://en.wikipedia.org/wiki/Hayes_Camp_Aerodrome",""
|
||||
"1685","CYCB","medium_airport","Cambridge Bay Airport","69.1081008911","-105.138000488","90","NA","CA","CA-NU","Cambridge Bay","yes","CYCB","YCB","CYCB","","","https://en.wikipedia.org/wiki/Cambridge_Bay_Airport",""
|
||||
"1674","CYBK","medium_airport","Baker Lake Airport","64.29889678960001","-96.077796936","59","NA","CA","CA-NU","Baker Lake","yes","CYBK","YBK","CYBK","","","https://en.wikipedia.org/wiki/Baker_Lake_Airport",""
|
||||
"28228","CJN8","seaplane_base","Fort Reliance Seaplane Base","62.7","-109.167","514","NA","CA","CA-NT","Fort Reliance","no","","YFL","CJN8","CJN8","","https://en.wikipedia.org/wiki/Fort_Reliance_Water_Aerodrome","JN8"
|
||||
"1111","CJQ6","small_airport","Tanquary Fiord Airport","81.409422","-76.867901","50","NA","CA","CA-NU","Tanquary Fiord","no","","","CJQ6","CJQ6","","https://en.wikipedia.org/wiki/Tanquary_Fiord_Airport","JQ6"
|
||||
"299180","CMB2","small_airport","Meadowbank Airport","65.0246615457","-96.06865882870001","475","NA","CA","CA-NU","Agnico-Eagle Meadowbank Gold Mine","no","","","CMB2","CMB2","","https://en.wikipedia.org/wiki/Meadowbank_Aerodrome",""
|
||||
"1308","CNH2","small_airport","Natuashish Airport","55.913898","-61.184399","30","NA","CA","CA-NL","Natuashish","yes","","YNP","CNH2","CNH2","","https://en.wikipedia.org/wiki/Natuashish_Airport","NH2"
|
||||
"1486","CRE2","small_airport","Edzo Airport","62.766701","-116.084","587","NA","CA","CA-NT","Rae-Edzo","no","","","CRE2","CRE2","","https://en.wikipedia.org/wiki/Rae/Edzo_Airport","RE2"
|
||||
"1718","CYEK","small_airport","Arviat Airport","61.0942","-94.070801","32","NA","CA","CA-NU","Arviat","yes","CYEK","YEK","CYEK","","","https://en.wikipedia.org/wiki/Arviat_Airport",""
|
||||
"1531","CSK6","small_airport","Snap Lake Airport","63.593601","-110.905998","1557","NA","CA","CA-NT","Snap Lake Mine","no","","YNX","","","","https://en.wikipedia.org/wiki/Snap_Lake_Airport","CSK6"
|
||||
"316099","CTM7","small_airport","Tundra Mine/Salamita Mine Aerodrome","64.0725","-111.158","1445","NA","CA","CA-NT","Tundra and Salamita Mines","no","","","CTM7","CTM7","","https://en.wikipedia.org/wiki/Tundra_Mine/Salamita_Mine_Aerodrome",""
|
||||
"1608","CTP9","small_airport","Donaldson Airport","61.662201","-73.321404","1902","NA","CA","CA-QC","Kattiniq","no","","YAU","CTP9","CTP9","","https://en.wikipedia.org/wiki/Kattiniq/Donaldson_Airport",""
|
||||
"1712","CYDP","small_airport","Nain Airport","56.550778","-61.682224","22","NA","CA","CA-NL","Nain","yes","CYDP","YDP","CYDP","","","https://en.wikipedia.org/wiki/Nain_Airport",""
|
||||
"300457","CWJC","small_airport","Ennadai Lake Airport","61.1333333333","-100.9","1021","NA","CA","CA-NU","Ennadai","no","","","CWJC","","","",""
|
||||
"46596","CYAB","small_airport","Arctic Bay Airport","73.006101","-85.04616","72","NA","CA","CA-NU","Arctic Bay","yes","CYAB","YAB","CYAB","","","https://en.wikipedia.org/wiki/Arctic_Bay_Airport","CJX7"
|
||||
"1659","CYAS","small_airport","Kangirsuk Airport","60.027199","-69.999199","403","NA","CA","CA-QC","Kangirsuk","yes","CYAS","YKG","CYAS","","","https://en.wikipedia.org/wiki/Kangirsuk_Airport","YAS"
|
||||
"1668","CYBB","small_airport","Kugaaruk Airport","68.535711","-89.805508","56","NA","CA","CA-NU","Kugaaruk","yes","CYBB","YBB","CYBB","","","https://en.wikipedia.org/wiki/Kugaaruk_Airport","YBB, Pelly Bay Townsite Airport"
|
||||
"1693","CYCO","small_airport","Kugluktuk Airport","67.816408","-115.14307","74","NA","CA","CA-NU","Kugluktuk","yes","CYCO","YCO","CYCO","","","https://en.wikipedia.org/wiki/Kugluktuk_Airport","Copper Mine Airport"
|
||||
"1697","CYCS","small_airport","Chesterfield Inlet Airport","63.346901","-90.731102","32","NA","CA","CA-NU","Chesterfield Inlet","yes","CYCS","YCS","CYCS","","","https://en.wikipedia.org/wiki/Chesterfield_Inlet_Airport",""
|
||||
"353838","CYD2","seaplane_base","Yellow Dog Lodge Seaplane Base","62.890556","-113.867778","725","NA","CA","CA-NT","Graham Lake","no","","","CYD2","CYD2","https://yellowdoglodge.ca/","https://en.wikipedia.org/wiki/Graham_Lake_(Yellow_Dog_Lodge)_Water_Aerodrome",""
|
||||
"1708","CYDL","small_airport","Dease Lake Airport","58.422199","-130.031998","2600","NA","CA","CA-BC","Dease Lake","yes","CYDL","YDL","CYDL","","","https://en.wikipedia.org/wiki/Dease_Lake_Airport","YDL"
|
||||
"1725","CYEU","small_airport","Eureka Airport","79.9946975708","-85.814201355","256","NA","CA","CA-NU","Eureka","no","CYEU","YEU","CYEU","","","https://en.wikipedia.org/wiki/Eureka_Aerodrome","YEU"
|
||||
"1742","CYGH","small_airport","Fort Good Hope Airport","66.240658","-128.647757","268","NA","CA","CA-NT","Fort Good Hope","yes","CYGH","YGH","CYGH","","","https://en.wikipedia.org/wiki/Fort_Good_Hope_Airport",""
|
||||
"1750","CYGT","small_airport","Igloolik Airport","69.3647","-81.816101","174","NA","CA","CA-NU","Igloolik","yes","CYGT","YGT","CYGT","","","https://en.wikipedia.org/wiki/Igloolik_Airport",""
|
||||
"1752","CYGW","medium_airport","Kuujjuarapik Airport","55.281898498535156","-77.76529693603516","34","NA","CA","CA-QC","Kuujjuarapik","yes","CYGW","YGW","CYGW","","","https://en.wikipedia.org/wiki/Kuujjuarapik_Airport",""
|
||||
"1754","CYGZ","small_airport","Grise Fiord Airport","76.425823","-82.908583","146","NA","CA","CA-NU","Grise Fiord","yes","CYGZ","YGZ","CYGZ","","","https://en.wikipedia.org/wiki/Grise_Fiord_Airport",""
|
||||
"1761","CYHI","small_airport","Ulukhaktok Holman Airport","70.762802","-117.806","117","NA","CA","CA-NT","Ulukhaktok","yes","CYHI","YHI","CYHI","","","https://en.wikipedia.org/wiki/Ulukhaktok/Holman_Airport",""
|
||||
"1762","CYHK","small_airport","Gjoa Haven Airport","68.635597","-95.849701","152","NA","CA","CA-NU","Gjoa Haven","yes","CYHK","YHK","CYHK","","","https://en.wikipedia.org/wiki/Gjoa_Haven_Airport",""
|
||||
"1765","CYHO","small_airport","Hopedale Airport","55.448757","-60.228124","39","NA","CA","CA-NL","Hopedale","yes","CYHO","YHO","CYHO","","","https://en.wikipedia.org/wiki/Hopedale_Airport","CC5"
|
||||
"1774","CYIK","small_airport","Ivujivik Airport","62.417301","-77.925301","126","NA","CA","CA-QC","Ivujivik","yes","CYIK","YIK","CYIK","","","https://en.wikipedia.org/wiki/Ivujivik_Airport",""
|
||||
"1775","CYIO","small_airport","Pond Inlet Airport","72.689494","-77.968941","181","NA","CA","CA-NU","Pond Inlet","yes","CYIO","YIO","CYIO","","","https://en.wikipedia.org/wiki/Pond_Inlet_Airport",""
|
||||
"1788","CYKG","small_airport","Kangiqsujuaq (Wakeham Bay) Airport","61.5886","-71.929398","501","NA","CA","CA-QC","Kangiqsujuaq","yes","CYKG","YWB","CYKG","","","https://en.wikipedia.org/wiki/Kangiqsujuaq_(Wakeham_Bay)_Airport",""
|
||||
"1791","CYKO","small_airport","Akulivik Airport","60.8186","-78.148598","75","NA","CA","CA-QC","Akulivik","yes","CYKO","AKV","CYKO","","","https://en.wikipedia.org/wiki/Akulivik_Airport","YKO"
|
||||
"1796","CYLA","small_airport","Aupaluk Airport","59.2967","-69.599701","119","NA","CA","CA-QC","Aupaluk","yes","CYLA","YPJ","CYLA","","","https://en.wikipedia.org/wiki/Aupaluk_Airport","YLA"
|
||||
"1806","CYLT","medium_airport","Alert Airport","82.517263","-62.283037","100","NA","CA","CA-NU","Alert","no","CYLT","YLT","CYLT","","","https://en.wikipedia.org/wiki/Alert_Airport","YLT"
|
||||
"1818","CYMU","small_airport","Umiujaq Airport","56.536098","-76.518303","250","NA","CA","CA-QC","Umiujaq","yes","CYMU","YUD","CYMU","","","https://en.wikipedia.org/wiki/Umiujaq_Airport","YMU"
|
||||
"1847","CYPH","small_airport","Inukjuak Airport","58.471901","-78.076897","83","NA","CA","CA-QC","Inukjuak","yes","CYPH","YPH","CYPH","","","https://en.wikipedia.org/wiki/Inukjuak_Airport",""
|
||||
"1860","CYPX","medium_airport","Puvirnituq Airport","60.050598","-77.286903","74","NA","CA","CA-QC","Puvirnituq","yes","CYPX","YPX","CYPX","","","https://en.wikipedia.org/wiki/Puvirnituq_Airport",""
|
||||
"1884","CYRA","small_airport","Rae Lakes Airport","64.116096","-117.309998","723","NA","CA","CA-NT","Gamètì","yes","CYRA","YRA","CYRA","","","https://en.wikipedia.org/wiki/Gam%C3%A8t%C3%AC/Rae_Lakes_Airport",""
|
||||
"1885","CYRB","medium_airport","Resolute Bay Airport","74.7169036865","-94.9693984985","215","NA","CA","CA-NU","Resolute Bay","yes","CYRB","YRB","CYRB","","","https://en.wikipedia.org/wiki/Resolute_Bay_Airport",""
|
||||
"1895","CYRT","medium_airport","Rankin Inlet Airport","62.8114013672","-92.1157989502","94","NA","CA","CA-NU","Rankin Inlet","yes","CYRT","YRT","CYRT","","","https://en.wikipedia.org/wiki/Rankin_Inlet_Airport",""
|
||||
"1905","CYSK","small_airport","Sanikiluaq Airport","56.536891","-79.250221","104","NA","CA","CA-NU","Sanikiluaq","yes","CYSK","YSK","CYSK","","","https://en.wikipedia.org/wiki/Sanikiluaq_Airport",""
|
||||
"1915","CYSY","small_airport","Sachs Harbour (David Nasogaluak Jr. Saaryuaq) Airport","71.993896","-125.242996","282","NA","CA","CA-NT","Sachs Harbour","yes","CYSY","YSY","CYSY","","","https://en.wikipedia.org/wiki/Sachs_Harbour_Airport",""
|
||||
"1918","CYTE","small_airport","Cape Dorset Airport","64.230003","-76.526703","164","NA","CA","CA-NU","Kinngait","yes","CYTE","YTE","CYTE","","","https://en.wikipedia.org/wiki/Cape_Dorset_Airport",""
|
||||
"1923","CYTQ","small_airport","Tasiujaq Airport","58.667801","-69.955803","122","NA","CA","CA-QC","Tasiujaq","yes","CYTQ","YTQ","CYTQ","","","https://en.wikipedia.org/wiki/Tasiujaq_Airport",""
|
||||
"1927","CYUB","small_airport","Tuktoyaktuk / James Gruben Airport","69.433296","-133.026001","15","NA","CA","CA-NT","Tuktoyaktuk","no","CYUB","YUB","CYUB","","","https://en.wikipedia.org/wiki/Tuktoyaktuk/James_Gruben_Airport",""
|
||||
"1929","CYUT","small_airport","Naujaat Airport","66.521024","-86.225166","80","NA","CA","CA-NU","Repulse Bay","yes","CYUT","YUT","CYUT","","","https://en.wikipedia.org/wiki/Repulse_Bay_Airport","Repulse Bay Airport"
|
||||
"1930","CYUX","medium_airport","Hall Beach Airport","68.7761","-81.2425","30","NA","CA","CA-NU","Sanirajak","yes","CYUX","YUX","CYUX","","","https://en.wikipedia.org/wiki/Hall_Beach_Airport",""
|
||||
"1939","CYVP","medium_airport","Kuujjuaq Airport","58.096099853515625","-68.4269027709961","129","NA","CA","CA-QC","Kuujjuaq","yes","CYVP","YVP","CYVP","","","https://en.wikipedia.org/wiki/Kuujjuaq_Airport",""
|
||||
"954","CYWE","small_airport","Wekweètì Airport","64.190804","-114.077002","1208","NA","CA","CA-NT","Wekweètì","yes","CYWE","YFJ","CYWE","CFJ7","","https://en.wikipedia.org/wiki/Wekwe%C3%A8t%C3%AC_Airport","Wekweti, Snare Lake"
|
||||
"1954","CYWY","medium_airport","Wrigley Airport","63.20940017700195","-123.43699645996094","489","NA","CA","CA-NT","Wrigley","no","CYWY","YWY","CYWY","","","https://en.wikipedia.org/wiki/Wrigley_Airport",""
|
||||
"1962","CYXN","small_airport","Whale Cove Airport","62.240002","-92.598099","40","NA","CA","CA-NU","Whale Cove","yes","CYXN","YXN","CYXN","","","https://en.wikipedia.org/wiki/Whale_Cove_Airport",""
|
||||
"1978","CYYH","small_airport","Taloyoak Airport","69.5467","-93.576698","92","NA","CA","CA-NU","Taloyoak","yes","CYYH","YYH","CYYH","","","https://en.wikipedia.org/wiki/Taloyoak_Airport","Spence Bay"
|
||||
"1984","CYYQ","medium_airport","Churchill Airport","58.739200592041016","-94.06500244140625","94","NA","CA","CA-MB","Churchill","yes","CYYQ","YYQ","CYYQ","","","https://en.wikipedia.org/wiki/Churchill_Airport",""
|
||||
"1994","CYZG","small_airport","Salluit Airport","62.179401","-75.667198","743","NA","CA","CA-QC","Salluit","yes","CYZG","YZG","CYZG","","","https://en.wikipedia.org/wiki/Salluit_Airport",""
|
||||
"1998","CYZS","medium_airport","Coral Harbour Airport","64.1932983398","-83.3593978882","210","NA","CA","CA-NU","Coral Harbour","yes","CYZS","YZS","CYZS","","","https://en.wikipedia.org/wiki/Coral_Harbour_Airport",""
|
||||
"2017","CZFM","small_airport","Fort Mcpherson Airport","67.406993","-134.860182","116","NA","CA","CA-NT","Fort Mcpherson","yes","CZFM","ZFM","CZFM","","","https://en.wikipedia.org/wiki/Fort_McPherson_Airport",""
|
||||
"2018","CZFN","small_airport","Tulita Airport","64.909496","-125.570297","332","NA","CA","CA-NT","Tulita","yes","CZFN","ZFN","CZFN","","","https://en.wikipedia.org/wiki/Tulita_Airport","Fort Norman"
|
||||
"307365","EGAH","small_airport","Halley Research Station","-75.567406","-25.516435","103","AN","AQ","AQ-U-A","Halley Research Station","no","EGAH","","EGAH","","","https://en.wikipedia.org/wiki/Halley_Research_Station","Halley, Antarctica, British Antarctic Survey"
|
||||
"324046","EGAT","small_airport","Sky Blu Airstrip","-74.849722","-71.566666","4740","AN","AQ","AQ-U-A","Sky Blu","no","EGAT","","EGAT","","https://www.bas.ac.uk/polar-operations/sites-and-facilities/facility/rothera/sky-blu/","https://en.wikipedia.org/wiki/Sky_Blu",""
|
||||
"31004","ENAS","small_airport","Ny-Ålesund Airport, Hamnerabben","78.927498","11.8743","50","EU","NO","NO-21","Ny-Ålesund","no","","","ENAS","","","https://en.wikipedia.org/wiki/Ny-%C3%85lesund_Airport,_Hamnerabben","Brøggerhalvøya Airport"
|
||||
"2877","FHAW","medium_airport","RAF Ascension Island","-7.970179","-14.392744","278","AF","SH","SH-AC","Cat Hill","yes","FHAW","ASI","FHAW","","https://www.raf.mod.uk/our-organisation/stations/ascension-island-base/","https://en.wikipedia.org/wiki/RAF_Ascension_Island","Wideawake Airfield, Ascension Island Auxiliary Field"
|
||||
"318444","FHSH","medium_airport","Saint Helena International Airport","-15.958898","-5.646066","1017","AF","SH","SH-SH","Jamestown","yes","FHSH","HLE","FHSH","","http://sthelenaairport.com/","https://en.m.wikipedia.org/wiki/Saint_Helena_Airport#",""
|
||||
"44073","GL-0006","small_airport","Summit Ice Landing Strip","72.616669","-38.5","10523","NA","GL","GL-NE","Summit Camp","no","","","","","","https://en.wikipedia.org/wiki/Summit_Camp","SUMUD"
|
||||
"44064","GL-0003","small_airport","NorthGRIP Airport","75.099998","-42.316666","","NA","GL","GL-NE","NorthGRIP","no","","","","","","","GIRPA, North Greenland Ice Core Project"
|
||||
"505046","GL-0014","small_airport","EastGRIP Airport","75.63","-35.99","","NA","GL","GL-NE","EastGRIP","no","","","","","","","East Greenland Ice-Core Project"
|
||||
"4582","HL56","medium_airport","Matan al-Sarra Air Base","21.6877","21.8309","1769","AF","LY","LY-KF","","no","","","HL56","HL56","","https://en.wikipedia.org/wiki/Maaten_al-Sarra_Air_Base",""
|
||||
"19520","KCN","seaplane_base","Chernofski Harbor Seaplane Base","53.402899","-167.520272","","NA","US","US-AK","Chernofski Harbor","no","","KCN","KCN","KCN","","http://wikimapia.org/6966644/Port-of-Chernofski-Harbor",""
|
||||
"26910","YBIE","medium_airport","Bedourie Airport","-24.3461","139.460007","300","OC","AU","AU-QLD","Bedourie","yes","YBIE","BEU","YBIE","","","https://en.wikipedia.org/wiki/Bedourie_Airport",""
|
||||
"308030","PAUT","small_airport","Akutan Airport","54.14459","-165.604332","133","NA","US","US-AK","Akutan","yes","PAUT","KQA","PAUT","7AK","https://web.archive.org/web/20100410064944/http://dot.alaska.gov/creg/akutan/assets/Akun_ALP_Sheet-Set-1-9.pdf","https://en.wikipedia.org/wiki/Akutan_Airport","Akun Airport"
|
||||
"31907","NCMH","small_airport","Manihiki Island Airport","-10.38091","-160.999424","11","OC","CK","CK-U-A","Manihiki Island","yes","NCMH","MHX","NCMH","","","https://en.wikipedia.org/wiki/Manihiki_Island_Airport","Humphrey Island, Te Fuinga o Niva"
|
||||
"32184","NCPY","small_airport","Tongareva Airport","-9.01437","-158.03241","8","OC","CK","CK-U-A","Penrhyn Island","no","NCPY","PYE","NCPY","","","https://en.wikipedia.org/wiki/Tongareva_Airport",""
|
||||
"4986","NTGE","medium_airport","Reao Airport","-18.46652","-136.43855","12","OC","PF","PF-U-A","Reao","no","NTGE","REA","NTGE","","","https://en.wikipedia.org/wiki/Reao_Airport",""
|
||||
"4989","NTGJ","medium_airport","Totegegie Airport","-23.07990074157715","-134.88999938964844","7","OC","PF","PF-U-A","","yes","NTGJ","GMR","NTGJ","","","https://en.wikipedia.org/wiki/Totegegie_Airport",""
|
||||
"32456","NTGO","small_airport","Tatakoto Airport","-17.355226","-138.44734","12","OC","PF","PF-U-A","Tatakoto","yes","NTGO","TKV","NTGO","","","https://en.wikipedia.org/wiki/Tatakoto_Airport",""
|
||||
"4992","NTGP","small_airport","Puka Puka Airport","-14.80955","-138.812621","5","OC","PF","PF-U-A","","yes","NTGP","PKP","NTGP","","","https://en.wikipedia.org/wiki/Puka-Puka_Airport",""
|
||||
"32177","NTGQ","small_airport","Pukaruha Airport","-18.295726","-137.016291","5","OC","PF","PF-U-A","Pukaruha","no","NTGQ","PUK","NTGQ","","","https://en.wikipedia.org/wiki/Pukarua_Airport",""
|
||||
"44636","NTGS","small_airport","Marutea Airport","-21.482194900512695","-135.63705444335938","10","OC","PF","PF-U-A","Auorotini","no","","","NTGS","","","https://en.wikipedia.org/wiki/Marutea_Sud",""
|
||||
"32055","NTGW","small_airport","Nukutavake Airport","-19.284613","-138.768367","17","OC","PF","PF-U-A","Nukutavake","no","NTGW","NUK","NTGW","","","https://en.wikipedia.org/wiki/Nukutavake_Airport",""
|
||||
"4996","NTGY","small_airport","Tureia Airport","-20.784262","-138.56796","12","OC","PF","PF-U-A","Tureia","no","NTGY","ZTA","NTGY","","","https://en.wikipedia.org/wiki/Tureia_Airport",""
|
||||
"31128","NTKH","small_airport","Fakahina Airport","-15.992259","-140.164246","3","OC","PF","PF-U-A","Fakahina","yes","NTKH","FHZ","NTKH","","","https://en.wikipedia.org/wiki/Fakahina_Airfield",""
|
||||
"5001","NTMD","medium_airport","Nuku Hiva Airport","-8.7956","-140.229004","220","OC","PF","PF-U-A","Nuku Hiva","yes","NTMD","NHV","NTMD","","","https://en.wikipedia.org/wiki/Nuku_Hiva_Airport",""
|
||||
"5002","NTMN","medium_airport","Hiva Oa-Atuona Airport","-9.76879024506","-139.011001587","1481","OC","PF","PF-U-A","Hiva Oa Island","yes","NTMN","AUQ","NTMN","","","https://en.wikipedia.org/wiki/Atuona_Airport","HIX"
|
||||
"32499","NTMP","small_airport","Ua Pou Airport","-9.35167","-140.078003","16","OC","PF","PF-U-A","Ua Pou","yes","NTMP","UAP","NTMP","","","https://en.wikipedia.org/wiki/Ua_Pou_Airport",""
|
||||
"32497","NTMU","small_airport","Ua Huka Airport","-8.936228","-139.554062","160","OC","PF","PF-U-A","Ua Huka","yes","NTMU","UAH","NTMU","","","https://en.wikipedia.org/wiki/Ua_Huka_Airport",""
|
||||
"32053","NTTX","small_airport","Moruroa Airport","-21.80978","-138.813385","7","OC","PF","PF-TG","Moruroa Atoll","no","NTTX","UOA","NTTX","","","","Mururoa, Mururura, Aopuni"
|
||||
"32573","NTUV","small_airport","Vahitahi Airport","-18.780697","-138.85568","9","OC","PF","PF-U-A","Vahitahi","no","NTUV","VHZ","NTUV","","","https://en.wikipedia.org/wiki/Vahitahi_Airport",""
|
||||
"11","NZ12","small_airport","Palmer Station Airport","-64.774582","-64.035907","149","AN","AQ","AQ-U-A","Palmer Station","no","","","NZ0B","NZ0B","","https://en.wikipedia.org/wiki/Palmer_Station",""
|
||||
"340683","NZFX","small_airport","Phoenix Airfield","-77.956389","166.766667","18","AN","AQ","AQ-U-A","McMurdo Station","no","","","NZFX","","","https://en.wikipedia.org/wiki/Phoenix_Airfield",""
|
||||
"16190","PAAK","small_airport","Atka Airport","52.220299","-174.205994","57","NA","US","US-AK","Atka","yes","PAAK","AKB","PAAK","AKA","","https://en.wikipedia.org/wiki/Atka_Airport","40A"
|
||||
"5354","PADU","medium_airport","Tom Madsen (Dutch Harbor) Airport","53.89881","-166.544996","22","NA","US","US-AK","Unalaska","yes","PADU","DUT","PADU","DUT","","https://en.wikipedia.org/wiki/Unalaska_Airport","Dutch Harbor, Unalaska"
|
||||
"16094","PAAP","seaplane_base","Port Alexander Seaplane Base","56.246799","-134.647995","","NA","US","US-AK","Port Alexander","yes","PAAP","PTD","PAAP","AHP","","https://en.wikipedia.org/wiki/Port_Alexander_Seaplane_Base","AK15"
|
||||
"5337","PABA","medium_airport","Barter Island Long Range Radar Station Airport","70.134003","-143.582001","2","NA","US","US-AK","Barter Island","yes","PABA","BTI","PABA","BTI","","https://en.wikipedia.org/wiki/Barter_Island_LRRS_Airport",""
|
||||
"5342","PABR","medium_airport","Wiley Post Will Rogers Memorial Airport","71.285402","-156.766008","44","NA","US","US-AK","Utqiaġvik","yes","PABR","BRW","PABR","BRW","","https://en.wikipedia.org/wiki/Wiley_Post-Will_Rogers_Memorial_Airport","Barrow"
|
||||
"9088","PACS","small_airport","Cape Sarichef Airport","54.583934","-164.905727","291","NA","US","US-AK","Cape Sarichef","no","PACS","WSF","PACS","26AK","","https://en.wikipedia.org/wiki/Cape_Sarichef_Airport",""
|
||||
"16092","PADK","medium_airport","Adak Airport","51.883564","-176.642783","18","NA","US","US-AK","Adak","yes","PADK","ADK","PADK","ADK","","https://en.wikipedia.org/wiki/Adak_Airport",""
|
||||
"5368","PAGM","medium_airport","Gambell Airport","63.767694","-171.733303","27","NA","US","US-AK","Gambell","yes","PAGM","GAM","PAGM","GAM","","https://en.wikipedia.org/wiki/Gambell_Airport",""
|
||||
"18251","PAIW","small_airport","Wales Airport","65.622593","-168.095","22","NA","US","US-AK","Wales","yes","PAIW","WAA","PAIW","IWK","","https://en.wikipedia.org/wiki/Wales_Airport_(Alaska)",""
|
||||
"19891","PAKF","small_airport","False Pass Airport","54.84751","-163.407168","20","NA","US","US-AK","False Pass","yes","PAKF","KFP","PAKF","KFP","","https://en.wikipedia.org/wiki/False_Pass_Airport",""
|
||||
"17980","PAKO","small_airport","Nikolski Air Station","52.941552","-168.849478","77","NA","US","US-AK","Nikolski","yes","PAKO","IKO","PAKO","IKO","","https://en.wikipedia.org/wiki/Nikolski_Air_Station",""
|
||||
"16165","PALN","small_airport","Point Lonely Short Range Radar Site Airfield","70.911118","-153.237766","17","NA","US","US-AK","Point Lonely","no","PALN","LNI","PALN","AK71","","https://en.wikipedia.org/wiki/Point_Lonely_Short_Range_Radar_Site","Lonely Air Station"
|
||||
"5385","PALU","medium_airport","Cape Lisburne LRRS Airport","68.875099","-166.110001","16","NA","US","US-AK","Cape Lisburne","yes","PALU","LUR","PALU","LUR","","https://en.wikipedia.org/wiki/Cape_Lisburne_LRRS_Airport",""
|
||||
"5396","PAPB","medium_airport","St George Airport","56.577345","-169.663823","125","NA","US","US-AK","St George","yes","PAPB","STG","PAPB","PBV","","https://en.wikipedia.org/wiki/St._George_Airport_(Alaska)","A8L"
|
||||
"24089","PAPO","small_airport","Point Hope Airport","68.348801","-166.798996","12","NA","US","US-AK","Point Hope","yes","PAPO","PHO","PAPO","PHO","","https://en.wikipedia.org/wiki/Point_Hope_Airport",""
|
||||
"5404","PASA","medium_airport","Savoonga Airport","63.686401","-170.492996","53","NA","US","US-AK","Savoonga","yes","PASA","SVA","PASA","SVA","","https://en.wikipedia.org/wiki/Savoonga_Airport",""
|
||||
"5410","PASN","medium_airport","St Paul Island Airport","57.166311","-170.222555","63","NA","US","US-AK","St Paul Island","yes","PASN","SNP","PASN","SNP","","https://en.wikipedia.org/wiki/St._Paul_Island_Airport","Pribilof"
|
||||
"5413","PASY","medium_airport","Eareckson Air Station","52.71229935","174.1139984","95","NA","US","US-AK","Shemya","no","PASY","SYA","PASY","SYA","","https://en.wikipedia.org/wiki/Eareckson_Air_Station",""
|
||||
"5416","PATQ","medium_airport","Atqasuk Edward Burnell Sr Memorial Airport","70.46704","-157.436013","96","NA","US","US-AK","Atqasuk","yes","PATQ","ATK","PATQ","ATK","","https://en.wikipedia.org/wiki/Atqasuk_Edward_Burnell_Sr._Memorial_Airport","Z46"
|
||||
"5419","PAVL","small_airport","Kivalina Airport","67.734565","-164.559917","13","NA","US","US-AK","Kivalina","yes","PAVL","KVL","PAVL","KVL","","https://en.wikipedia.org/wiki/Kivalina_Airport",""
|
||||
"5422","PAWI","medium_airport","Wainwright Airport","70.638","-159.994995","41","NA","US","US-AK","Wainwright","yes","PAWI","AIN","PAWI","AWI","","https://en.wikipedia.org/wiki/Wainwright_Airport_(Alaska)","5WW"
|
||||
"5426","PCIS","medium_airport","Canton Island Airport","-2.7681200504300003","-171.710006714","9","OC","KI","KI-P","Abariringa","no","PCIS","CIS","PCIS","","","https://en.wikipedia.org/wiki/Canton_Island_Airport","Kanton Island"
|
||||
"5494","PMDY","medium_airport","Henderson Field","28.2017","-177.380997","13","OC","UM","UM-71","Sand Island","no","PMDY","MDY","PMDY","MDY","","https://en.wikipedia.org/wiki/Henderson_Field_(Midway_Atoll)",""
|
||||
"5495","PPIZ","medium_airport","Point Lay LRRS Airport","69.732903","-163.005005","22","NA","US","US-AK","Point Lay","yes","PPIZ","PIZ","PPIZ","PIZ","","https://en.wikipedia.org/wiki/Point_Lay_LRRS_Airport",""
|
||||
"5506","PWAK","medium_airport","Wake Island Airfield","19.282437","166.636637","14","OC","UM","UM-79","Wake Island","yes","PWAK","AWK","PWAK","AWK","","https://en.wikipedia.org/wiki/Wake_Island_Airfield",""
|
||||
"5534","RJAM","small_airport","JMSDF Minami Torishima Air Base","24.2897","153.979004","22","AS","JP","JP-13","Ogasawara","no","RJAM","MUS","RJAM","","","https://en.wikipedia.org/wiki/Minami_Torishima_Airport","Marcus Island Airport"
|
||||
"5535","RJAW","medium_airport","Ioto (Iwo Jima) Airbase","24.784153","141.32261","384","AS","JP","JP-13","Ogasawara","no","RJAW","IWO","RJAW","","","https://en.wikipedia.org/wiki/Central_Field_(Iwo_Jima)","Iwo Jima Airport, Central Field, Ioto, Iōtō"
|
||||
"42980","UESU","medium_airport","Zyryanka Airport","65.7485","150.8889","140","AS","RU","RU-SA","Zyryanka","yes","UESU","ZKP","UESU","","","https://en.wikipedia.org/wiki/Zyryanka_Airport","УЕСУ, Аэропорт Зырянка"
|
||||
"323626","RU-0472","small_airport","Lamutskoye Airport","65.538838","168.844848","601","AS","RU","RU-CHU","Lamutskoye","no","","","UHAL","UHAL","","","УХАЛ, Ламутское"
|
||||
"338091","RU-0543","small_airport","Tempa Airfield","75.76713","137.576637","3","EU","RU","RU-SA","Kotelny Island","no","","","","","","","Темп"
|
||||
"6456","UHMA","medium_airport","Ugolny Yuri Ryktheu Airport","64.734902","177.740997","194","AS","RU","RU-CHU","Anadyr","yes","UHMA","DYR","UHMA","","","https://en.wikipedia.org/wiki/Ugolny_Airport","УХМА, Угольный, Аnadyr Airport, Аэропорт Анадырь"
|
||||
"340795","RU-0605","small_airport","Uelkal Airstrip","65.530823","-179.287263","","EU","RU","RU-CHU","Uelkal","no","","","","","","",""
|
||||
"340810","RU-0616","small_airport","Taymylyr Airstrip","72.607271","121.893179","","EU","RU","RU-SA","Taymylyr","no","","","","","","",""
|
||||
"341832","RU-0676","small_airport","Ayanka Airport","63.709269","167.584196","295","AS","RU","RU-KAM","Ayanka","no","","","","","","",""
|
||||
"346936","RU-0727","small_airport","Khailino Airport","60.95206","166.83801","180","AS","RU","RU-KAM","Khailino","no","","","","UHPF","","","УХПФ, Хаилино"
|
||||
"42940","UHPA","small_airport","Ust-Pakhachi Airport","60.556587","169.107707","10","AS","RU","RU-KAM","Ust-Pakhachi","no","UHPA","","UHPA","UHPA","","https://en.wikipedia.org/wiki/Ust-Pakhachi_Airport","УХПА, Аэропорт Усть-Пахачи, Пахачи, Pakhachi"
|
||||
"346939","RU-0730","small_airport","Sredny Pakhachi Airport","60.81718","169.07555","","AS","RU","RU-KAM","Sredny Pakhachi","no","","","","","","",""
|
||||
"346948","RU-0738","small_airport","Garmanda Airport","62.18334","159.08894","","AS","RU","RU-MAG","Garmanda","no","","","","","","","UEVE"
|
||||
"347894","RU-0753","small_airport","Zhilinda Airport","70.15387","113.91756","489","EU","RU","RU-SA","Zhilinda","no","","","","","","",""
|
||||
"347895","RU-0754","small_airport","Ebelyakh Airport","70.88319","113.58159","387","EU","RU","RU-SA","Ebelyakh","no","","","","","","",""
|
||||
"347902","RU-0760","small_airport","Yukta Airport","63.38788","105.65577","680","EU","RU","RU-KYA","Yukta","no","","","","","","",""
|
||||
"347903","RU-0761","small_airport","Nakanno Airport","62.89988","108.44788","816","EU","RU","RU-IRK","Nakanno","no","","","","","","",""
|
||||
"347904","RU-0762","small_airport","Khamakar Airport","61.989","108.17978","889","EU","RU","RU-IRK","Khamakar","no","","","","","","",""
|
||||
"601237","RU-10132","small_airport","Yagodnoye Airfield","62.54574","149.52257","1646","AS","RU","RU-MAG","Yagodnoye","no","","","UHGE","","","","УХГЕ, Ягодное,"
|
||||
"602171","RU-10134","small_airport","Valunistoe (airport)","66.421687","177.582464","","AS","RU","RU-CHU","Rudnik Valunisty","no","","","","","","",""
|
||||
"602186","RU-10135","small_airport","Valunistoe Mining Complex Airport","66.766633","164.319284","999","AS","RU","RU-CHU","Vesenny","no","","","","","","",""
|
||||
"602304","RU-10136","small_airport","Verkhnee Penzhino Airport","64.222657","164.244866","1093","AS","RU","RU-KAM","Penzhinsky District","no","","","","","","","Верхнее Пенжино"
|
||||
"609963","RU-10173","small_airport","Stadukhino Airfield","66.965897","166.82135","1905","AS","RU","RU-CHU","Stadukhino","no","","","","ZC8P","","","ЗЦ8П, Стадукхино"
|
||||
"34989","RU-2510","small_airport","Greem Bell Air Base","81.15","64.33","30","EU","RU","RU-ARK","Graham Bell Island","no","","","","XLDG","","https://en.wikipedia.org/wiki/Greem_Bell","Graham Bell Airbase, Аэродром Греэм Белл, Аэродром Грээм Белл, ЬЛДГ"
|
||||
"39078","SAYE","small_airport","Esperanza Base","-63.400002","-56.983299","75","AN","AQ","AQ-U-A","Fortín Sargento Cabral","no","","","SAYE","BET","","https://en.wikipedia.org/wiki/Esperanza_Base",""
|
||||
"5830","SAWB","small_airport","Gustavo Marambio Airport","-64.238297","-56.630798","760","AN","AQ","AQ-U-A","Marambio Base","no","","","SAWB","MBI","","https://en.wikipedia.org/wiki/Marambio_Airport",""
|
||||
"39076","SAWZ","small_airport","Matienzo Airfield","-64.976998","-60.108992","75","AN","AQ","AQ-U-A","Matienzo Base","no","","","SAWZ","BTM","","",""
|
||||
"39077","SAYB","small_airport","Base Belgrano II Airport","-77.874055","-34.62616","820","AN","AQ","AQ-U-A","Base Belgrano II","no","","","SAYB","BEB","","https://en.wikipedia.org/wiki/Belgrano_II_Base",""
|
||||
"6039","SCRM","small_airport","Teniente Rodolfo Marsh Martin Airport","-62.191026","-58.98669","147","AN","AQ","AQ-U-A","Villa Las Estrellas","no","SCRM","TNM","SCRM","","","https://en.wikipedia.org/wiki/Teniente_R._Marsh_Airport",""
|
||||
"39080","SAYS","small_airport","San Martín Base Airfield","-68.116699","-67.099998","75","AN","AQ","AQ-U-A","San Martín Base","no","","","SAYS","BSM","","https://en.wikipedia.org/wiki/San_Martin_Base",""
|
||||
"308226","SCBO","small_airport","General Bernardo O'Higgins Base Skyway","-63.344","-57.842","900","AN","AQ","AQ-U-A","General Bernardo O'Higgins Base","no","","","SCBO","","","",""
|
||||
"6021","SCFX","small_airport","Isla San Felix Airport","-26.2939","-80.096199","165","SA","CL","CL-AT","Isla San Felix","no","","","SCFX","","","https://en.wikipedia.org/wiki/Isla_San_Felix_Airport",""
|
||||
"45059","SCGC","small_airport","Union Glacier Blue-Ice Runway","-79.777778","-83.320833","2461","AN","AQ","AQ-U-A","Union Glacier","no","SCGC","UGL","SCGC","","","https://en.wikipedia.org/wiki/Union_Glacier_Blue-Ice_Runway",""
|
||||
"32268","SCPZ","small_airport","Patriot Hills Airport","-80.318307","-81.308128","2900","AN","AQ","AQ-U-A","Patriot Hills Base Camp","no","SCPZ","","SCPZ","","","https://es.wikipedia.org/wiki/Aer%C3%B3dromo_Patriot_Hills",""
|
||||
"6055","SEGS","medium_airport","Seymour Galapagos Ecological Airport","-0.453758","-90.2659","207","SA","EC","EC-W","Isla Baltra","yes","SEGS","GPS","SEGS","","https://ecogal.aero/en/","https://en.wikipedia.org/wiki/Seymour_Airport",""
|
||||
"41131","SEII","small_airport","General Villamil Airport","-0.942628","-90.953003","35","SA","EC","EC-W","Puerto Villamil","yes","SEII","IBB","SEII","","","",""
|
||||
"32508","UEBN","small_airport","Nizhneyansk Airport","71.437822","136.189701","20","AS","RU","RU-SA","Nizhneyansk","no","","","UEBN","","","","Нижнея́нск"
|
||||
"335165","UEKY","small_airport","Keng-Kyuyol Airport","68.076828","145.985126","","EU","RU","RU-SA","Keng-Kyuyol","no","","","UEKY","","","",""
|
||||
"335179","UEMC","small_airport","Khatyngnakh Airport","67.489845","152.575679","","EU","RU","RU-SA","","no","","","UEMC","","","",""
|
||||
"44597","UERO","small_airport","Olenyok Airport","68.514999","112.480003","847","AS","RU","RU-SA","Olenyok","no","","ONK","UERO","","","https://en.wikipedia.org/wiki/Olenyok_Airport","УЕРО, Olenek Airport, Аэропорт Оленёк, Аэропорт Оленек"
|
||||
"44870","UERS","medium_airport","Saskylakh Airport","71.927902","114.080002","","AS","RU","RU-SA","Saskylakh","yes","UERS","SYS","UERS","","","https://en.wikipedia.org/wiki/Saskylakh_Airport","УЕРС, Аэропорт Саскылах"
|
||||
"335193","UESC","small_airport","Berezovka Airport","67.120183","156.589766","","AS","RU","RU-SA","","no","","","UESC","","","","УЕСЦ, Берёзовка"
|
||||
"43667","UESG","medium_airport","Belaya Gora Airport","68.556227","146.22784","118","AS","RU","RU-SA","Belaya Gora","no","UESG","BGN","UESG","","","https://en.wikipedia.org/wiki/Belaya_Gora_Airport","УЕСГ, Аэропорт Белая Гора"
|
||||
"335194","UESH","small_airport","Argakhtakh Airport","68.443333","153.362222","65","AS","RU","RU-SA","Argakhtakh","no","","","UESH","","","","УЕСХ, Аргахтах"
|
||||
"316761","UESK","medium_airport","Srednekolymsk Airport","67.4805","153.7364","60","AS","RU","RU-SA","Srednekolymsk","yes","","SEK","UESK","","","https://en.wikipedia.org/wiki/Srednekolymsk_Airport","УЕСК, Среднеколымск"
|
||||
"335195","UESL","small_airport","Sylgy-Ytar Airport","67.848145","154.838862","164","AS","RU","RU-SA","","no","","","UESL","","","","УЕСЛ, Сылгы-Ытар"
|
||||
"32514","UESO","medium_airport","Chokurdakh Airport","70.6231","147.901993","151","AS","RU","RU-SA","Chokurdah","yes","UESO","CKH","UESO","","","https://en.wikipedia.org/wiki/Chokurdakh_Airport","Chokurdah Airport, Cokurdah Airport, Аэропорт Чокурдах"
|
||||
"335196","UESR","small_airport","Tumat Airport","70.72611","139.235833","","EU","RU","RU-SA","","no","","","UESR","","","",""
|
||||
"30870","UESS","medium_airport","Cherskiy Airport","68.740601","161.337997","20","AS","RU","RU-SA","Cherskiy","yes","UESS","CYX","UESS","","","https://en.wikipedia.org/wiki/Chersky_Airport","УЕСС, Chersky Airport, Аэропорт Черский"
|
||||
"6450","UEST","medium_airport","Tiksi Airport","71.697700500488","128.90299987793","26","AS","RU","RU-SA","Tiksi","yes","UEST","IKS","UEST","","","https://en.wikipedia.org/wiki/Tiksi_Airport","УЕСТ, Аеропорт Тикси"
|
||||
"335197","UESV","small_airport","Svatay Airport","68.057338","151.79625","75","AS","RU","RU-SA","Svatay","no","","","UESW","","","","УЕСВ, Сватай"
|
||||
"335198","UESX","small_airport","Aleko-Kyuyol Airport","68.676693","151.873798","82","AS","RU","RU-SA","","no","","","UESX","","","","УЕСЬ, Алеко-Кюель"
|
||||
"335200","UESZ","small_airport","Kazachye Airport","70.746294","136.179271","","EU","RU","RU-SA","","no","","","UESZ","","","",""
|
||||
"323629","UHAC","small_airport","Chuvanskoye Airport","65.172163","167.952484","644","AS","RU","RU-CHU","Chuvanskoye","no","","","UHAC","UHAC","","","УХАЦ, Чуванское"
|
||||
"323630","UHAW","small_airport","Vayegi Airport","64.167442","171.057887","131","AS","RU","RU-CHU","Vayegi","no","","","UHAW","UHAW","","","УХАВ, Ваеги"
|
||||
"324924","UHDW","small_airport","Vankarem Airport","67.838719","-175.8494283","3","EU","RU","RU-CHU","Vankarem","no","","","UHDW","","","","УХДВ, Ванкарем"
|
||||
"530668","UHEI","small_airport","Ilirney Airport","67.255278","167.949167","1165","AS","RU","RU-CHU","","no","","","UHEI","","","",""
|
||||
"525055","UHEK","small_airport","Kupol Airport","66.901667","169.56","1745","AS","RU","RU-CHU","","no","","","UHEK","","","https://en.wikipedia.org/wiki/Kupol_Airport",""
|
||||
"596406","UHGL","small_airport","Glukharinoye Airfield","64.999872","153.096328","977","AS","RU","RU-MAG","Glukharinoye","no","","","UHGL","","","","УХГЛ, Глухариное, Глухариный"
|
||||
"6457","UHMD","medium_airport","Provideniya Bay Airport","64.378098","-173.242996","72","AS","RU","RU-CHU","Chukotka","no","UHMD","PVS","UHMD","XHMD","","https://en.wikipedia.org/wiki/Provideniya_Bay_Airport","УХМД, ЬХМД, Bukhta Provideniya Airport, Urelik Airport, Ureliki Airport, Аэропорт Бухта Провидения, Аэропорт Урелики"
|
||||
"43686","UHME","small_airport","Zaliv Kresta Airport","66.358398","-179.108393","22","AS","RU","RU-CHU","Egvekinot","no","","","UHME","","","","УХМЕ, Аэропорт Залив Креста"
|
||||
"44659","UHMF","medium_airport","Omsukchan Airport","62.457001","155.744995","1732","AS","RU","RU-MAG","Omsukchan","yes","UHMF","","UHMF","","","https://en.wikipedia.org/wiki/Omsukchan_Airport","Аэропорт Омсукчан"
|
||||
"35040","UHMI","small_airport","Mys Shmidta Airport","68.86808","-179.374183","20","AS","RU","RU-CHU","Mys Shmidta","no","UHMI","","UHMI","","","https://en.wikipedia.org/wiki/Mys_Shmidta_Airport","УХМИ, ЬХМИ, Мыс Шмидта"
|
||||
"44473","UHMK","medium_airport","Keperveem Airport","67.845001","166.139999","623","AS","RU","RU-CHU","Keperveem","yes","UHMK","KPW","UHMK","","","https://en.wikipedia.org/wiki/Keperveyem_Airport","Keperveyem Airport, Аэропорт Кепервеем"
|
||||
"42925","UHML","small_airport","Lavrentiya Airport","65.580426","-170.999177","30","AS","RU","RU-CHU","Lavrentiya","no","UHML","","UHML","","","https://en.wikipedia.org/wiki/Lavrentiya_Airport","УХМЛ, Залив Лаврентия, Lavrentiya Bay"
|
||||
"44610","UHMN","small_airport","Omolon Airport","65.239998","160.539993","869","AS","RU","RU-CHU","Omolon","yes","UHMN","","UHMN","","","https://en.wikipedia.org/wiki/Omolon_Airport","УХМН, Аэропорт Омолон"
|
||||
"44660","UHMO","small_airport","Markovo Airport","64.665446","170.417626","75","AS","RU","RU-CHU","Markovo","yes","UHMO","KVM","UHMO","","","https://en.wikipedia.org/wiki/Markovo_Airport","Аэропорт Марково"
|
||||
"6459","UHMP","medium_airport","Pevek Airport","69.783302","170.597","11","AS","RU","RU-CHU","Apapelgino","yes","UHMP","PWE","UHMP","","","https://en.wikipedia.org/wiki/Pevek_Airport","УХМП, Аэропорт Певек"
|
||||
"32518","UHMR","small_airport","Beringovskiy Airport","63.02","179.292999","3","AS","RU","RU-CHU","Beringovsky","no","UHMR","","UHMR","","","https://es.wikipedia.org/wiki/Aer%C3%B3dromo_de_Beringovski","УХМР, Аэропорт Беринговский"
|
||||
"42963","UHMS","medium_airport","Seymchan Airport","62.92078","152.422775","679","AS","RU","RU-MAG","Seymchan","no","UHMS","","UHMS","","","https://en.wikipedia.org/wiki/Seymchan_Airport","УХМС, Аэропорт Сеймчан"
|
||||
"324631","UHMZ","small_airport","Wrangel Island Airport","70.945356","-179.5","112","EU","RU","RU-CHU","Wrangel Island","no","","","UHMZ","UHMZ","","","УХМЗ, Остров Врангеля"
|
||||
"339646","UHPN","small_airport","Manily Airport","62.485962","165.338917","33","AS","RU","RU-KAM","Manily","no","","","UHPN","","","",""
|
||||
"597129","UHQE","small_airport","Slautnoye Airfield","63.176423","167.978281","","AS","RU","RU-KAM","Slautnoye","no","","","UHQE","","","","УХЯЕ, Слаутное"
|
||||
"42926","UIKE","small_airport","Yerbogachen Airport","61.2750015259","108.029998779","400","AS","RU","RU-IRK","Erbogachen","no","UIKE","ERG","UIKE","","","https://en.wikipedia.org/wiki/Erbogachen_Airport","Yerbogachyon Airport, Erbogachyon Airport, Erbogachen Airport, Аэропорт Ербогачен, Аэропорт Ербогачён"
|
||||
"43672","ULDW","small_airport","Varandey Airport","68.848503","58.201401","23","AS","RU","RU-NEN","Varandey","no","ULDW","VRI","ULDW","","","https://en.wikipedia.org/wiki/Varandey_Airport","УЛДВ, Varandei Airport, Аэропорт Варандей"
|
||||
"315363","UODL","small_airport","Cape Chelyuskin Airfield","77.712","104.24","60","AS","RU","RU-KYA","Chelyuskin Polar Station","no","","","UODL","UODL","","","УОДЛ, Челюскин"
|
||||
"35041","UODN","medium_airport","Nagurskoye Airfield","80.803207","47.663586","59","AS","RU","RU-ARK","Alexandra Land","no","UODN","","UODN","","","https://en.wikipedia.org/wiki/Nagurskoye",""
|
||||
"35086","UODS","small_airport","Sredniy Island Air Base","79.52958","91.07495","26","AS","RU","RU-KYA","Khatanga","no","UODS","","UODS","XODS","","https://en.wikipedia.org/wiki/Sredny_Ostrov","УОДС, Sredniy Ostrov Air Base, Аэродром Средний Остров, ЬОДС"
|
||||
"301864","US-0200","small_airport","Kogru River Airfield","70.574196","-152.259478","","NA","US","US-AK","Kogru","no","","","","A41","","",""
|
||||
"340805","US-3039","small_airport","Brownlow Point Airport","69.979281","-144.834986","","NA","US","US-AK","Brownlow Point","no","","","","","","",""
|
||||
"340807","US-3041","small_airport","Demarcation Bay Airfield","69.882551","-142.305616","","NA","US","US-AK","Nuvagapak Point","no","","","","","","","Nuvagapak Point"
|
||||
"316465","WAN","small_airport","Waverney Airport","-25.356917","141.925593","570","OC","AU","AU-QLD","Waverney","no","","","","","","","WAN"
|
||||
"354533","YABL","small_airport","Ambalindum Airport","-23.35187","134.732434","1966","OC","AU","AU-NT","Ambalindum","no","","","YABL","","","",""
|
||||
"27317","YADO","small_airport","Andado Airport","-25.407772","135.290794","549","OC","AU","AU-NT","Ghan","no","","","YADO","","","",""
|
||||
"38224","YALC","small_airport","Alcoota Station Airport","-22.801963","134.4036","1994","OC","AU","AU-NT","Alcoota","no","","","YALC","","","","Engawala"
|
||||
"38226","YALM","small_airport","Allambie Airport","-24.265335","134.402927","","OC","AU","AU-NT","","no","","","YALM","","","",""
|
||||
"38229","YAMM","small_airport","Ammaroo Airport","-21.738300323486328","135.24200439453125","","OC","AU","AU-NT","","no","","AMX","YAMM","","","",""
|
||||
"27328","YAMT","small_airport","Amata Airport","-26.097363","131.202732","695","OC","AU","AU-SA","","no","YAMT","AMT","YAMT","","","https://en.wikipedia.org/wiki/Amata_Airport",""
|
||||
"38230","YANJ","small_airport","Angatja Airport","-26.100000381469727","130.39999389648438","","OC","AU","AU-SA","","no","","","YANJ","","","",""
|
||||
"38231","YANN","small_airport","Anningie Airport","-21.83169937133789","133.125","","OC","AU","AU-NT","","no","","","YANN","","","",""
|
||||
"27332","YANW","small_airport","Annitowa Airport","-21.200017","136.451139","901","OC","AU","AU-NT","","no","","","YANW","","","",""
|
||||
"27336","YARD","small_airport","Argadargada Airport","-21.676700592041016","136.67799377441406","","OC","AU","AU-NT","","no","","","YARD","","","",""
|
||||
"27340","YARN","small_airport","Areyonga Airport","-24.091565","132.271228","","OC","AU","AU-NT","Mereenie","no","","","YARN","","","",""
|
||||
"38232","YARP","small_airport","Arapunya Airport","-22.316699981689453","135.6999969482422","","OC","AU","AU-NT","","no","","","YARP","","","",""
|
||||
"38233","YATL","small_airport","Atula Airport","-23.255344","136.388955","915","OC","AU","AU-NT","Anatye","no","","","YATL","","","",""
|
||||
"26895","YAYE","medium_airport","Ayers Rock Connellan Airport","-25.185913","130.97703","1626","OC","AU","AU-NT","Yulara","yes","YAYE","AYQ","YAYE","","","https://en.wikipedia.org/wiki/Ayers_Rock_Airport","Ayers Rock, Uluru"
|
||||
"26900","YBAS","medium_airport","Alice Springs Airport","-23.806588","133.903427","1789","OC","AU","AU-NT","Alice Springs","yes","YBAS","ASP","YBAS","","http://www.alicespringsairport.com.au/","https://en.wikipedia.org/wiki/Alice_Springs_Airport",""
|
||||
"26907","YBDV","medium_airport","Birdsville Airport","-25.897499084472656","139.34800720214844","159","OC","AU","AU-QLD","","yes","YBDV","BVI","YBDV","","","https://en.wikipedia.org/wiki/Birdsville_Airport",""
|
||||
"27369","YBEO","small_airport","Betoota Airport","-25.694791","140.738339","91","OC","AU","AU-QLD","","no","YBEO","BTX","YBEO","","","",""
|
||||
"38256","YBRC","small_airport","Barrow Creek Airport","-21.542202","133.871959","","OC","AU","AU-NT","","no","","","YBRC","","","",""
|
||||
"27416","YBSR","small_airport","Blackstone Airport","-25.9867000579834","128.28799438476562","","OC","AU","AU-WA","","no","","","YBSR","","","",""
|
||||
"38278","YCCK","small_airport","Canteen Creek Airport","-20.650800704956055","135.5850067138672","","OC","AU","AU-NT","","no","","","YCCK","","","",""
|
||||
"38293","YCNS","small_airport","Coniston Airport","-22.13330078125","132.5330047607422","","OC","AU","AU-NT","","no","","","YCNS","","","",""
|
||||
"348022","YCSK","small_airport","Casey Station Skiway","-66.288056","110.7575","1178","AN","AQ","AQ-U-A","Casey Station","no","","","YCSK","","","https://en.wikipedia.org/wiki/Casey_Station",""
|
||||
"38304","YCSP","small_airport","Curtin Springs Airport","-25.333921","131.759308","","OC","AU","AU-NT","","no","","","YCSP","","","",""
|
||||
"38308","YCUD","small_airport","Cuddapan Airport","-25.59000015258789","141.50799560546875","","OC","AU","AU-QLD","","no","","","YCUD","","","",""
|
||||
"38460","YMDS","small_airport","Macdonald Downs Airport","-22.444000244099996","135.199005127","","OC","AU","AU-NT","","no","YMDS","MNW","YMDS","","","",""
|
||||
"27532","YDRI","small_airport","Durrie Airport","-25.684999465942383","140.22799682617188","58","OC","AU","AU-QLD","","no","YDRI","DRR","YDRI","","","",""
|
||||
"38330","YDRL","small_airport","De Rose Hill Airport","-26.435838","133.259954","","OC","AU","AU-SA","De Rose Hill","no","","","YDRL","","","",""
|
||||
"27535","YDVR","small_airport","Docker River Airport","-24.859533","129.122209","589","OC","AU","AU-NT","Kaltukatjara","no","YDVR","DKV","YDVR","","","",""
|
||||
"38334","YDYD","small_airport","Derry Downs Airport","-22.08329963684082","135.33299255371094","","OC","AU","AU-NT","","no","","","YDYD","","","",""
|
||||
"38338","YELK","small_airport","Elkedra Airport","-21.172500610399997","135.444000244","","OC","AU","AU-NT","","no","","EKD","YELK","","","",""
|
||||
"38342","YEPR","small_airport","Epenarra Airport","-20.433300018310547","135.26699829101562","","OC","AU","AU-NT","","no","","","YEPR","","","",""
|
||||
"38345","YERL","small_airport","Erldunda Airport","-25.206106","133.254161","","OC","AU","AU-NT","Ghan","no","","EDD","YERL","","","",""
|
||||
"27554","YERN","small_airport","Ernabella Airport","-26.2632999420166","132.1820068359375","707","OC","AU","AU-SA","","no","YERN","ERB","YERN","","","",""
|
||||
"27559","YEVP","small_airport","Everard Park Airport","-27.038988","132.688585","1615","OC","AU","AU-SA","Mimili","no","","","YEVP","","","",""
|
||||
"31129","YFNE","small_airport","Finke Airport","-25.5946998596","134.582992554","","OC","AU","AU-NT","Finke","no","YFNE","FIK","YFNE","","","",""
|
||||
"27566","YFRG","small_airport","Fregon Airport","-26.774999618530273","132.01699829101562","528","OC","AU","AU-SA","","no","","","YFRG","","","",""
|
||||
"26985","YFRT","medium_airport","Forrest Airport","-30.83662","128.112811","511","OC","AU","AU-WA","","no","YFRT","FOS","YFRT","","","https://en.wikipedia.org/wiki/Forrest_Airport",""
|
||||
"27631","YIDK","small_airport","Indulkana Airport","-26.965435","133.321989","401","OC","AU","AU-SA","Indulkana","no","YIDK","IDK","YIDK","","","",""
|
||||
"27594","YGLE","small_airport","Glengyle Airport","-24.808300018310547","139.60000610351562","91","OC","AU","AU-QLD","","no","YGLE","GLG","YGLE","","","",""
|
||||
"27599","YGLS","small_airport","Giles Airport","-25.043699","128.296005","579","OC","AU","AU-WA","Warakurna","no","","","YGLS","","","https://en.wikipedia.org/wiki/Giles_Airport",""
|
||||
"27612","YHAA","small_airport","Haasts Bluff Airport","-23.454999923706","131.85299682617","2269","OC","AU","AU-NT","","no","","","YHAA","","","",""
|
||||
"27619","YHKT","small_airport","Huckitta Airport","-22.943300247192383","135.44700622558594","","OC","AU","AU-NT","","no","","","YHKT","","","",""
|
||||
"27621","YHMB","small_airport","Hermannsburg Airport","-23.926485","132.807798","593","OC","AU","AU-NT","Hermannsburg (Ntaria)","no","YHMB","HMG","YHMB","","","",""
|
||||
"38390","YHSB","small_airport","Horseshoe Bend Airport","-25.216699600219727","134.26699829101562","","OC","AU","AU-NT","","no","","","YHSB","","","",""
|
||||
"38394","YHTS","small_airport","Harts Range Airport","-22.985000610351562","134.91799926757812","","OC","AU","AU-NT","","no","","","YHTS","","","",""
|
||||
"27632","YIDR","small_airport","Idracowra Airport","-25.06329917907715","133.73300170898438","","OC","AU","AU-NT","","no","","","YIDR","","","",""
|
||||
"38396","YIKL","small_airport","Baikal Airport","-22.7632999420166","136.16299438476562","","OC","AU","AU-NT","","no","","","YIKL","","","",""
|
||||
"27647","YJAM","small_airport","Jameson Airport","-25.850000381469727","127.68299865722656","","OC","AU","AU-WA","","no","","","YJAM","","","",""
|
||||
"38407","YKCA","small_airport","Kings Canyon Airport","-24.260229","131.488924","2100","OC","AU","AU-NT","Petermann","no","YKCA","KBJ","YKCA","","","",""
|
||||
"38425","YLBG","small_airport","Mount Liebig Airport","-23.24329948425293","131.25999450683594","","OC","AU","AU-NT","","no","","","YLBG","","","",""
|
||||
"27719","YLGN","small_airport","Loongana Airport","-30.940771","127.028781","","OC","AU","AU-WA","","no","","","YLGN","","","",""
|
||||
"27727","YLLC","small_airport","Lilla Creek Airport","-25.55791","134.07114","","OC","AU","AU-NT","","no","","","YLLC","","","",""
|
||||
"38444","YLSY","small_airport","Mount Lindsay Airport","-27.018299102783203","129.88499450683594","","OC","AU","AU-SA","","no","","","YLSY","","","",""
|
||||
"27749","YMAD","small_airport","Madura Airport","-31.887825","127.025573","","OC","AU","AU-WA","","no","","","YMAD","","","",""
|
||||
"32730","YMBX","small_airport","Mundrabilla Airport","-31.86720085144043","127.85399627685547","120","OC","AU","AU-WA","Mundrabilla","no","","","YMBX","","","",""
|
||||
"38455","YMBZ","small_airport","Mount Ebenezer Airport","-25.169763","132.643107","","OC","AU","AU-NT","","no","","","YMBZ","","","",""
|
||||
"27763","YMDV","small_airport","Mount Davies Airport","-26.16670036315918","129.13299560546875","","OC","AU","AU-SA","","no","","","YMDV","","","",""
|
||||
"27789","YMNK","small_airport","Monkira Airport","-24.816699981689453","140.5330047607422","107","OC","AU","AU-QLD","","no","YMNK","ONR","YMNK","","","",""
|
||||
"27185","YYND","small_airport","Yuendumu Airport","-22.2542","131.781998","2205","OC","AU","AU-NT","","no","YYND","YUE","YYND","","","https://en.wikipedia.org/wiki/Yuendumu_Airport",""
|
||||
"31943","YMQA","small_airport","Marqua Airport","-22.80579948425293","137.25100708007812","","OC","AU","AU-NT","Marqua","no","YMQA","MQE","YMQA","","","",""
|
||||
"38495","YMSK","small_airport","Mount Skinner Airport","-22.187801","134.171648","","OC","AU","AU-NT","","no","","","YMSK","","","",""
|
||||
"27812","YMTX","small_airport","Mount Dare Airport","-26.06170082092285","135.2469940185547","","OC","AU","AU-SA","","no","","","YMTX","","","",""
|
||||
"38500","YMUP","small_airport","Mulga Park Airport","-25.860000610399997","131.649993896","","OC","AU","AU-NT","","no","YMUP","MUP","YMUP","","","",""
|
||||
"27823","YMWE","small_airport","Mount Wedge Airport","-22.737199783325195","132.1510009765625","559","OC","AU","AU-NT","","no","","","YMWE","","","",""
|
||||
"38507","YMYV","small_airport","Maryvale Airport","-24.647992","134.026358","","OC","AU","AU-NT","","no","","","YMYV","","","",""
|
||||
"38508","YMYW","small_airport","Murray Downs Airport","-21.04829978942871","134.68299865722656","","OC","AU","AU-NT","","no","","","YMYW","","","",""
|
||||
"38513","YNHV","small_airport","New Haven Airport","-22.728300094604492","131.14500427246094","","OC","AU","AU-NT","","no","","","YNHV","","","",""
|
||||
"27850","YNRR","small_airport","Nyrripi Airport","-22.645299911499023","130.56500244140625","","OC","AU","AU-NT","","no","","","YNRR","","","",""
|
||||
"38523","YNUE","small_airport","Numery Airport","-23.983299255371094","135.39999389648438","","OC","AU","AU-NT","","no","","","YNUE","","","",""
|
||||
"38528","YNWT","small_airport","Narwietooma Airport","-23.228786","132.629378","","OC","AU","AU-NT","","no","","","YNWT","","","",""
|
||||
"38535","YORA","small_airport","Moonerah Airport","-31.691413","126.590523","","OC","AU","AU-WA","","no","","","YORA","","","",""
|
||||
"38537","YORT","small_airport","Ooratippra Airport","-21.905799865722656","136.06900024414062","","OC","AU","AU-NT","","no","","","YORT","","","",""
|
||||
"354815","YPHS","small_airport","Pine Hill Station Airport","-22.386977","133.06025","2142","OC","AU","AU-NT","Anmatjere","no","","","YPHS","","","",""
|
||||
"27924","YRNW","small_airport","Ringwood Airport","-23.82830047607422","134.96200561523438","","OC","AU","AU-NT","","no","","","YRNW","","","",""
|
||||
"27991","YTIT","small_airport","Ti Tree Airport","-22.131701","133.419998","","OC","AU","AU-NT","","no","","","YTIT","","","https://en.wikipedia.org/wiki/Ti-Tree_Airfield",""
|
||||
"38607","YTJU","small_airport","Tjuntjuntjarra Airport","-29.344938","127.107348","740","OC","AU","AU-WA","","no","","","YTJU","","","",""
|
||||
"38609","YTLT","small_airport","Tarlton Downs Airport","-22.63330078125","136.8000030517578","","OC","AU","AU-NT","","no","","","YTLT","","","",""
|
||||
"32491","YTMY","small_airport","Tobermorey Airport","-22.255800247192383","137.9530029296875","","OC","AU","AU-NT","Tobermorey","no","YTMY","TYP","YTMY","","","",""
|
||||
"28014","YTYN","small_airport","Tieyon Airport","-26.216699600219727","133.91700744628906","","OC","AU","AU-SA","","no","","","YTYN","","","",""
|
||||
"38623","YUMB","small_airport","Umbeara Airport","-25.746700286865234","133.70199584960938","","OC","AU","AU-NT","","no","","","YUMB","","","",""
|
||||
"38625","YUTP","small_airport","Utopia Airport","-22.05500030517578","134.7899932861328","","OC","AU","AU-NT","","no","","","YUTP","","","",""
|
||||
"38629","YVNS","small_airport","Vaughan Springs Airport","-22.33329963684082","130.86700439453125","","OC","AU","AU-NT","","no","","","YVNS","","","",""
|
||||
"27165","YWBR","small_airport","Warburton Airport","-26.125538","126.583158","1500","OC","AU","AU-WA","Warburton","no","","","YWBR","","","https://en.wikipedia.org/wiki/Warburton_Airport",""
|
||||
"302312","YWKS","small_airport","Wilkins Runway","-66.690833","111.523611","2529","AN","AQ","AQ-U-A","Preston Heath","no","","","YWKS","","","https://en.wikipedia.org/wiki/Wilkins_Runway",""
|
||||
"38645","YWLA","small_airport","Willowra Airport","-21.27669906616211","132.6230010986328","","OC","AU","AU-NT","","no","","","YWLA","","","",""
|
||||
"38647","YWLH","small_airport","Wallara Ranch Airport","-24.649999618530273","132.31700134277344","","OC","AU","AU-NT","","no","","","YWLH","","","",""
|
||||
|
||||
|
+4
-5
@@ -8,11 +8,10 @@ services:
|
||||
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
||||
# Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
|
||||
- "./db:/app/db"
|
||||
environment:
|
||||
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
||||
# /api/test/expire-package so the suites can force known routes/expiries;
|
||||
# 404 without this var.
|
||||
FLIGHT_TEST_HOOKS: "1"
|
||||
# No environment on purpose (#135, v0.62.0): FLIGHT_TEST_HOOKS must NOT be set in production —
|
||||
# it enables the /api/test/* hooks AND makes tickOnce honor legacy stored timeScale values
|
||||
# (pre-v0.52.0 saves hold 1000). The e2e suite runs only in CI against docker-compose.ci.yml,
|
||||
# which sets the var itself.
|
||||
networks:
|
||||
- dev_net
|
||||
labels:
|
||||
|
||||
+19
-9
@@ -50,9 +50,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
||||
@@ -93,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
|
||||
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is why the expiry
|
||||
// test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health
|
||||
// and no gate afterwards.
|
||||
async function activateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, 'plane-starter', { health: 50 });
|
||||
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
||||
@@ -176,6 +182,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
// Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own
|
||||
// dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so
|
||||
// exact-cash assertions never see that cost.
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
}
|
||||
|
||||
|
||||
@@ -334,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
|
||||
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
|
||||
// baseline below is captured AFTER deactivateWorld, not before it.
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
@@ -23,6 +23,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package
|
||||
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
|
||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// ISS-129 (v0.57.0): /api/events pushes full state snapshots over SSE — the current snapshot on
|
||||
// connect, then one after every sim tick (~1 s; the background tick runs even while idle since
|
||||
// v0.56.0, so no scale hook is needed). Reached via the same base URL as playwright.config.ts
|
||||
// (E2E_BASE_URL, defaulting to the live instance) — node-side fetch, no browser involved.
|
||||
const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl';
|
||||
|
||||
interface StateBody {
|
||||
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 () => {
|
||||
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
expect(res.body).not.toBeNull();
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
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 {
|
||||
// Collect two snapshot events: the initial one, then at least one pushed by a background tick.
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (clocks.length < 2 && Date.now() < deadline) {
|
||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||
try {
|
||||
chunk = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSE stream stalled')), Math.max(1, deadline - Date.now())),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
break; // stalled or past the deadline — let the length assertion report it
|
||||
}
|
||||
if (chunk.done) break; // stream ended early — same
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
for (const line of frame.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping")
|
||||
const snap = JSON.parse(line.slice('data: '.length)) as StateBody;
|
||||
clocks.push(snap.simState.clockMs);
|
||||
for (const p of snap.packages ?? []) statusesSeen.add(p.status);
|
||||
maxFrameBytes = Math.max(maxFrameBytes, line.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel(); // close the stream → server unsubscribes
|
||||
}
|
||||
|
||||
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
||||
// A grounded fleet freezes the clock (#135), so consecutive frames may carry the SAME clockMs —
|
||||
// non-decreasing is what proves real pushes, not replays (advance itself is pinned in sim.spec.ts).
|
||||
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(clocks[1]).toBeGreaterThanOrEqual(clocks[0]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's
|
||||
// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas.
|
||||
import { haversineKm } from '../lib/geo';
|
||||
import { travelDurationMs } from '../lib/sim/movement';
|
||||
import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog';
|
||||
import { EXPIRY_BUFFER_MS } from '../lib/game/seed';
|
||||
|
||||
// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to
|
||||
// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even
|
||||
// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1)
|
||||
// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on
|
||||
// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or
|
||||
// map queries are involved (the assertion is self-contained in the hook response), so none of those
|
||||
// conventions apply here.
|
||||
|
||||
const FROM = 'EDDB'; // Berlin Brandenburg
|
||||
const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM
|
||||
const COUNT = 10; // priority packages created and checked
|
||||
|
||||
interface Pkg {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
destIcao: string;
|
||||
createdSimTime: number;
|
||||
deadlineSimTime: number;
|
||||
}
|
||||
|
||||
async function createPriorityPackage(page: Page, from: string, to: string): Promise<Pkg> {
|
||||
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } });
|
||||
expect(res.status()).toBe(201);
|
||||
return (await res.json()) as Pkg;
|
||||
}
|
||||
|
||||
test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => {
|
||||
// The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses.
|
||||
const res = await page.request.get('/api/airports');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
||||
const from = fc.features.find((f) => f.properties.icao === FROM)?.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === TO)?.properties;
|
||||
expect(from, `${FROM} missing from the airport dataset`).toBeTruthy();
|
||||
expect(to, `${TO} missing from the airport dataset`).toBeTruthy();
|
||||
if (!from || !to) return; // guarded above — keeps the math below total
|
||||
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous
|
||||
const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
|
||||
const created: Pkg[] = [];
|
||||
try {
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const pkg = await createPriorityPackage(page, FROM, TO);
|
||||
expect(pkg.originIcao).toBe(FROM);
|
||||
expect(pkg.destIcao).toBe(TO);
|
||||
// The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation.
|
||||
// deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly.
|
||||
const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime;
|
||||
expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs));
|
||||
// And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane.
|
||||
expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000);
|
||||
created.push(pkg);
|
||||
}
|
||||
} finally {
|
||||
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
|
||||
for (const pkg of created) {
|
||||
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||||
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
+20
-18
@@ -48,9 +48,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
@@ -84,33 +89,30 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
// Self-heal (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an
|
||||
// interrupted run, let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
const s0 = await getState(page);
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before acting on the starter.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
let starter = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
|
||||
@@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
@@ -115,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;
|
||||
@@ -332,3 +338,245 @@ 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+25
-7
@@ -16,9 +16,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
@@ -34,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
async function activateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||
@@ -146,7 +151,7 @@ test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, I
|
||||
// 409s on non-available planes — it must be grounded before activateWorld.
|
||||
await flyHomeViaApi(page);
|
||||
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
||||
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for full scale.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
await page.goto('/');
|
||||
@@ -192,3 +197,16 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
|
||||
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
|
||||
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// App Router file convention: app/icon.svg is auto-served and injected into the head.
|
||||
const link = page.locator('head link[rel~="icon"]');
|
||||
await expect(link).toHaveCount(1);
|
||||
// Production builds append a content-hash query string to the auto-injected icon href.
|
||||
expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/);
|
||||
|
||||
const res = await page.request.get('/icon.svg');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('image/svg+xml');
|
||||
});
|
||||
|
||||
+11
-4
@@ -47,9 +47,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
@@ -567,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.
|
||||
|
||||
@@ -21,9 +21,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||
|
||||
+17
-12
@@ -70,9 +70,14 @@ interface StateBody {
|
||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||
@@ -267,19 +272,19 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
||||
// The abandon button previews the penalty at its snapshot's clock (same formula as the server).
|
||||
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
|
||||
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
|
||||
const frozenClock = (await getState(page)).simState.clockMs;
|
||||
// other planes), so the world clock is FROZEN (#135) — verify it actually is, then the preview must
|
||||
// match the server exactly: a frozen clock means zero drift between any two snapshots.
|
||||
const c0 = (await getState(page)).simState.clockMs;
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||||
expect(
|
||||
(await getState(page)).simState.clockMs,
|
||||
'world should be frozen while the fleet is idle (ISS-117)',
|
||||
).toBe(frozenClock);
|
||||
const state = await getState(page);
|
||||
expect(state.simState.clockMs - c0, 'the grounded clock must stay frozen (#135)').toBe(0);
|
||||
|
||||
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
|
||||
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
|
||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||
expect(Number(penaltyText), 'frozen clock → the preview matches the server exactly').toBe(expectedPenalty);
|
||||
|
||||
// Fly to the destination through the UI — the button is the destination's name.
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
|
||||
+22
-15
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
|
||||
// fleet, background-tick advance at a raised rate while something is active, package expiry via
|
||||
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
|
||||
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
||||
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
||||
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
|
||||
// market top-up.
|
||||
|
||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
@@ -23,9 +24,14 @@ interface StateBody {
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
@@ -41,9 +47,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
async function activateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||
@@ -65,14 +71,15 @@ test.afterEach(async ({ page }) => {
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
|
||||
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
|
||||
const s = await getState(page);
|
||||
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||
const frozen = s.simState.clockMs;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(500);
|
||||
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
|
||||
}
|
||||
|
||||
const c0 = s.simState.clockMs;
|
||||
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
|
||||
const after = await getState(page);
|
||||
|
||||
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
|
||||
});
|
||||
|
||||
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
||||
|
||||
+7
-6
@@ -42,8 +42,8 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
|
||||
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
||||
// Self-heal to a fully grounded fleet first — a grounded fleet freezes the world clock (#135),
|
||||
// so no tick advances it across the restart and the post-restart clock is exactly unchanged.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/state');
|
||||
@@ -76,10 +76,11 @@ test('state survives a container restart (sim clock + account persisted)', async
|
||||
expect(after).toBeDefined();
|
||||
const a = after!;
|
||||
|
||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
|
||||
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
|
||||
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
||||
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
|
||||
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, the world
|
||||
// clock is FROZEN (#135) — no tick advances it while the container is down or after restart, so
|
||||
// the post-restart clock is EXACTLY unchanged; a re-seed would reset clockMs backwards instead.
|
||||
expect(a.simState.clockMs - before.simState.clockMs).toBe(0);
|
||||
expect(a.account.cash).toBe(before.account.cash);
|
||||
expect(a.account.reputation).toBe(before.account.reputation);
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
|
||||
+8
-3
@@ -18,9 +18,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
||||
|
||||
@@ -67,9 +67,14 @@ interface StateBody {
|
||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
|
||||
+61
-1
@@ -1,5 +1,7 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { assertUniqueIcaos, filterWorldAirports, parseCsv, toGeoJson, airportsHandler } from './airports';
|
||||
import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports';
|
||||
|
||||
const HEADER =
|
||||
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
|
||||
@@ -132,3 +134,61 @@ describe('/api/airports handler', () => {
|
||||
expect(await a.json()).toEqual(await b.json());
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemote (v0.61.0 — ISS-097 special remote airports)', () => {
|
||||
it('synthetic: an isolated point is remote; a neighbour inside the radius is not', () => {
|
||||
// Equator pair: Δlon 8.5° ≈ 945 km (inside REMOTE_RADIUS_KM) vs Δlon 9.5° ≈ 1052 km (outside).
|
||||
const near = [
|
||||
{ icao: 'AAA', lat: 0, lon: 0 },
|
||||
{ icao: 'BBB', lat: 0, lon: 8.5 },
|
||||
];
|
||||
expect(isRemote(near, 'AAA')).toBe(false);
|
||||
expect(isRemote(near, 'BBB')).toBe(false);
|
||||
|
||||
const far = [
|
||||
{ icao: 'AAA', lat: 0, lon: 0 },
|
||||
{ icao: 'CCC', lat: 0, lon: 9.5 },
|
||||
];
|
||||
expect(isRemote(far, 'AAA')).toBe(true);
|
||||
expect(isRemote(far, 'CCC')).toBe(true);
|
||||
|
||||
// A lone airport has no other member to be near — remote by definition.
|
||||
expect(isRemote([{ icao: 'SOLO', lat: -77.84, lon: 166.91 }], 'SOLO')).toBe(true);
|
||||
});
|
||||
|
||||
it('real coords: Frankfurt↔Amsterdam (~370 km) is not remote; McMurdo vs Christchurch (~3800 km) is', () => {
|
||||
const eu = [
|
||||
{ icao: 'EDDF', lat: 50.0376, lon: 8.5622 }, // Frankfurt Main
|
||||
{ icao: 'EHAM', lat: 52.3093, lon: 13.3698 }, // Amsterdam Schiphol
|
||||
];
|
||||
expect(isRemote(eu, 'EDDF')).toBe(false);
|
||||
|
||||
const pacific = [
|
||||
{ icao: 'MCDD', lat: -77.8425, lon: 166.9133 }, // McMurdo Station (Antarctica)
|
||||
{ icao: 'NZCH', lat: -43.4893, lon: 172.5308 }, // Christchurch
|
||||
];
|
||||
expect(isRemote(pacific, 'MCDD')).toBe(true);
|
||||
expect(REMOTE_RADIUS_KM).toBe(1000);
|
||||
});
|
||||
|
||||
it('an unknown ICAO is not remote', () => {
|
||||
expect(isRemote([{ icao: 'AAA', lat: 0, lon: 0 }], 'ZZZ')).toBe(false);
|
||||
});
|
||||
|
||||
it('committed dataset curation rule: every non-large row has no large airport within REMOTE_RADIUS_KM', async () => {
|
||||
const text = await fs.readFile(path.join(process.cwd(), 'data', 'airports_world.csv'), 'utf8');
|
||||
const rows = filterWorldAirports(parseCsv(text));
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
|
||||
const large = rows.filter((r) => r.type === 'large_airport').map((r) => ({ icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg }));
|
||||
const nonLarge = rows.filter((r) => r.type !== 'large_airport');
|
||||
// The dataset must actually carry the "special" airports — not just large ones.
|
||||
expect(nonLarge.length).toBeGreaterThan(0);
|
||||
|
||||
for (const r of nonLarge) {
|
||||
const self = { icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg };
|
||||
// Remote against the kept set: no LARGE airport within REMOTE_RADIUS_KM (the curation rule).
|
||||
expect(isRemote([...large, self], self.icao), `${self.icao} (${r.name}) must be remote`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+27
-3
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { haversineKm } from './geo';
|
||||
|
||||
export type CsvRow = Record<string, string>;
|
||||
|
||||
@@ -52,15 +53,38 @@ export function parseCsv(text: string): CsvRow[] {
|
||||
// The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a
|
||||
// single "airport" type + category column) — both spellings are accepted. Heliports are
|
||||
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are
|
||||
// irrelevant to an airline-ops game. The committed dataset is pre-curated to large_airport
|
||||
// only (scripts/build-world-airports.mjs); this check stays as a cheap guard against an
|
||||
// uncurated dump being swapped in.
|
||||
// irrelevant to an airline-ops game. The committed dataset is pre-curated by
|
||||
// scripts/build-world-airports.mjs: every large_airport plus the small/medium/seaplane rows
|
||||
// that are REMOTE (see isRemote) — this check stays as a cheap guard against an uncurated
|
||||
// dump being swapped in.
|
||||
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
||||
|
||||
export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
|
||||
return rows.filter((r) => INCLUDED_TYPES.has(r.type));
|
||||
}
|
||||
|
||||
// v0.61.0 (ISS-097): "special" remote airports — the issue's loneliness test: an airport is
|
||||
// remote when no other member of `airports` sits within this radius, so it stands alone in a
|
||||
// genuinely empty region (Antarctica, high Arctic, far islands) rather than being one of many.
|
||||
export const REMOTE_RADIUS_KM = 1000;
|
||||
|
||||
export interface RemoteAirport {
|
||||
icao: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
// True when the airport with this ICAO has no OTHER member of `airports` within REMOTE_RADIUS_KM
|
||||
// (haversine). Unknown ICACOs are not remote — callers only pass rows they intend to include.
|
||||
export function isRemote(airports: readonly RemoteAirport[], icao: string): boolean {
|
||||
const a = airports.find((x) => x.icao === icao);
|
||||
if (!a) return false;
|
||||
for (const b of airports) {
|
||||
if (b.icao !== a.icao && haversineKm(a.lat, a.lon, b.lat, b.lon) <= REMOTE_RADIUS_KM) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface AirportProperties {
|
||||
name: string;
|
||||
icao: string;
|
||||
|
||||
+6
-4
@@ -211,12 +211,14 @@ export async function persist(instance: GameDb): Promise<void> {
|
||||
await fs.rename(tmp, instance.file);
|
||||
}
|
||||
|
||||
// Singleton for the app process; openGameDb() is the uncached variant (tests).
|
||||
let ready: Promise<GameDb> | null = null;
|
||||
// Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0):
|
||||
// lives on globalThis because Next.js can load this module more than once per process —
|
||||
// instrumentation.ts and route handlers get separate module instances, so a plain `let` would give
|
||||
// each its own in-memory sql.js DB (both persisting to the same file). One world per process.
|
||||
const g = globalThis as unknown as { __flightGameDb?: Promise<GameDb> };
|
||||
|
||||
export function getGameDb(): Promise<GameDb> {
|
||||
if (!ready) ready = open(defaultDbPath());
|
||||
return ready;
|
||||
return (g.__flightGameDb ??= open(defaultDbPath()));
|
||||
}
|
||||
|
||||
export async function openGameDb(file: string): Promise<GameDb> {
|
||||
|
||||
@@ -97,6 +97,11 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
},
|
||||
};
|
||||
|
||||
// v0.54.0 (ISS-119): fastest cruise speed in the catalog — a generated job must be finishable by
|
||||
// the best available aircraft, so seed's expiry floor references this, not the starter's 510 km/h
|
||||
// (that would wreck long-job economics). Data-driven: adding a faster plane tightens the floor.
|
||||
export const FASTEST_CATALOG_SPEED_KMH = Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh));
|
||||
|
||||
// The plane the player starts with.
|
||||
export const STARTER_MODEL = 'atr72';
|
||||
|
||||
|
||||
+84
-1
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { travelDurationMs } from '@/lib/sim/movement';
|
||||
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { SPECIALS } from './specials';
|
||||
import {
|
||||
BASE_REWARD,
|
||||
buildSeed,
|
||||
EXPIRY_BUFFER_MS,
|
||||
JOB_BONUS_PER_REP,
|
||||
MAX_JOB_BONUS,
|
||||
PACKAGE_COUNT_MAX,
|
||||
@@ -437,3 +439,84 @@ describe('distance cap (v0.44.0, ISS-079)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiry floor (v0.54.0, ISS-119)', () => {
|
||||
// Pre-v0.54.0 deadline for a forced-endpoint + forced-special package: replays the fixed draw order
|
||||
// (cargoType, cargoCount, weight jitter, window — forced inputs consume no draws) so short-leg
|
||||
// packages can be pinned byte-identical to the old behavior.
|
||||
const oldDeadlineFor = (seed: number, scale: number): number => {
|
||||
const rng = mulberry32(seed);
|
||||
rng(); // cargoType
|
||||
rng(); // cargoCount
|
||||
rng(); // weight jitter
|
||||
const w = rng(); // deadline window
|
||||
return Math.round(NOW + (6 + w * (72 - 6)) * 3_600_000 * scale);
|
||||
};
|
||||
|
||||
it('pins the constants: fastest catalog speed (not the starter) and a flat 1 h buffer', () => {
|
||||
expect(FASTEST_CATALOG_SPEED_KMH).toBe(Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh)));
|
||||
expect(FASTEST_CATALOG_SPEED_KMH).toBeGreaterThan(PLANE_TYPES[STARTER_MODEL].speedKmh);
|
||||
expect(EXPIRY_BUFFER_MS).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('every generated package on the real dataset satisfies deadline ≥ now + travel time at max speed + buffer', async () => {
|
||||
const { loadAirportsGeoJson } = await import('@/lib/airports');
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
|
||||
const o = airports.find((a) => a.icao === c.originIcao)!;
|
||||
const d = airports.find((a) => a.icao === c.destIcao)!;
|
||||
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
|
||||
}
|
||||
});
|
||||
|
||||
it('long legs near MAX_JOB_LEG_KM are clamped up to the floor for every special (real dataset)', async () => {
|
||||
const { loadAirportsGeoJson } = await import('@/lib/airports');
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
|
||||
// Farthest pair within the cap — a leg as long as any generated job can be.
|
||||
let bestI = -1;
|
||||
let bestJ = -1;
|
||||
let bestKm = 0;
|
||||
for (let i = 0; i < airports.length; i++) {
|
||||
for (let j = i + 1; j < airports.length; j++) {
|
||||
const km = haversineKm(airports[i].lat, airports[i].lon, airports[j].lat, airports[j].lon);
|
||||
if (km <= MAX_JOB_LEG_KM && km > bestKm) {
|
||||
bestI = i;
|
||||
bestJ = j;
|
||||
bestKm = km;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
|
||||
|
||||
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
for (const special of ['priority', null] as const) {
|
||||
let clamped = 0;
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const c = generatePackage({ id: `c-long-${special ?? 'plain'}-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW, originIcao: airports[bestI].icao, destIcao: airports[bestJ].icao, special });
|
||||
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); // the invariant
|
||||
if (c.deadlineSimTime === Math.round(NOW + floorMs)) clamped++; // landed exactly on the floor → the clamp fired
|
||||
}
|
||||
// Deterministic for these fixed seeds — proves the clamp actually engages, not just vacuously holds.
|
||||
expect(clamped).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('short legs are byte-identical to the pre-v0.54.0 draw (no economy regression)', () => {
|
||||
// EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h — below even the priority minimum window (3 h), so the clamp never fires here.
|
||||
for (const seed of [1, 2, 3, 4, 5]) {
|
||||
const plain = generatePackage({ id: 'c-short', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null });
|
||||
expect(plain.deadlineSimTime).toBe(oldDeadlineFor(seed, 1));
|
||||
const prio = generatePackage({ id: 'c-short-p', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: 'priority' });
|
||||
expect(prio.deadlineSimTime).toBe(oldDeadlineFor(seed, SPECIALS.priority.deadlineScale));
|
||||
}
|
||||
});
|
||||
|
||||
it('forced deadline offsets still skip the floor (e2e expiry hook)', () => {
|
||||
const c = generatePackage({ id: 'c-floor-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null, deadlineOffsetMs: 10_000 });
|
||||
expect(c.deadlineSimTime).toBe(NOW + 10_000); // the hook's near-term expiry is untouched by the floor
|
||||
});
|
||||
});
|
||||
|
||||
+18
-3
@@ -1,6 +1,7 @@
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { travelDurationMs } from '@/lib/sim/movement';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { pickSpecial, SPECIALS } from './specials';
|
||||
|
||||
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
||||
@@ -123,6 +124,10 @@ const LEG_CAP_MAX_ATTEMPTS = 25;
|
||||
const DEADLINE_MIN_HOURS = 6;
|
||||
const DEADLINE_MAX_HOURS = 72;
|
||||
|
||||
// v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at
|
||||
// least this much after the fastest plane lands, so dispatch + landing are never instant.
|
||||
export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h
|
||||
|
||||
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
||||
|
||||
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
|
||||
@@ -157,6 +162,9 @@ export function rewardFor(km: number, weightKg: number): number {
|
||||
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
||||
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
||||
// starting value so seed-time behavior is byte-identical.
|
||||
// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at
|
||||
// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is
|
||||
// finishable by the fastest catalog plane; short legs are byte-identical to before.
|
||||
export function generatePackage(input: {
|
||||
id: string;
|
||||
airports: SeedAirport[];
|
||||
@@ -230,8 +238,15 @@ export function generatePackage(input: {
|
||||
// forced value (including explicit null) skips it, same pattern as the other overrides.
|
||||
const special = input.special !== undefined ? input.special : pickSpecial(rng);
|
||||
const mods = special !== null ? SPECIALS[special] : undefined;
|
||||
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
|
||||
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
|
||||
// v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane:
|
||||
// deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a
|
||||
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs
|
||||
// (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls
|
||||
// below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of
|
||||
// airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate
|
||||
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling.
|
||||
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
|
||||
|
||||
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||||
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const { db } = await getGameDb();
|
||||
// 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 { 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<string, AirportCoord>([
|
||||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||||
@@ -45,7 +48,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): 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 +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: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||
);
|
||||
|
||||
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: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
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: [] },
|
||||
],
|
||||
}),
|
||||
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]);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
it('returns an empty collection for a null snapshot', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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_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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => {
|
||||
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
|
||||
@@ -51,6 +51,22 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events': {
|
||||
get: {
|
||||
summary: 'Live state stream (SSE)',
|
||||
description:
|
||||
'Server-Sent Events feed of full game-state snapshots (v0.57.0, ISS-129) — the current snapshot is sent immediately on connect and a fresh one after every sim tick (~1 s), so clients can subscribe instead of polling /api/state. Each event\'s data payload is JSON with exactly the StateSnapshot shape; comment lines starting with ":" are heartbeats.',
|
||||
operationId: 'subscribeToEvents',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'SSE stream of state snapshots (text/event-stream)',
|
||||
content: {
|
||||
'text/event-stream': { schema: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/version': {
|
||||
get: {
|
||||
summary: 'App version',
|
||||
|
||||
+50
-13
@@ -30,8 +30,9 @@ import {
|
||||
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
||||
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
|
||||
|
||||
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
|
||||
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
|
||||
// #135: fresh DBs seed a GROUNDED starter, so any test that needs clock advance must put a plane in
|
||||
// flight first (a fully grounded fleet freezes the world) — departureSimTime at the game-clock
|
||||
// origin keeps its arrival hours away.
|
||||
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
||||
@@ -44,11 +45,15 @@ const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(T0);
|
||||
// #135 (v0.62.0): this file exercises the FLIGHT_TEST_HOOKS branch of tickOnce — stored
|
||||
// sim.timeScale overrides are only honored when the env var is set, exactly like CI.
|
||||
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -60,7 +65,7 @@ async function freshDb(name: string) {
|
||||
describe('tickOnce — clock advance', () => {
|
||||
it('advances the clock by elapsed real ms × timeScale', async () => {
|
||||
const inst = await freshDb('scale.db');
|
||||
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117)
|
||||
await makeWorldActive(inst); // a fully grounded fleet freezes the clock (#135)
|
||||
vi.setSystemTime(T0 + 1_000); // one real second later
|
||||
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
||||
|
||||
@@ -70,15 +75,33 @@ describe('tickOnce — clock advance', () => {
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||
});
|
||||
|
||||
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
|
||||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
|
||||
it('production (no FLIGHT_TEST_HOOKS): a legacy stored timeScale is ignored — always WORLD_TIME_SCALE (#135)', async () => {
|
||||
const inst = await freshDb('prod-rate.db');
|
||||
await makeWorldActive(inst);
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // what a pre-v0.52.0 save holds
|
||||
|
||||
vi.setSystemTime(T0 + 1_000);
|
||||
const saved = process.env.FLIGHT_TEST_HOOKS;
|
||||
delete process.env.FLIGHT_TEST_HOOKS;
|
||||
let sim: schema.SimStateRow;
|
||||
try {
|
||||
sim = await tickOnce(inst);
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.FLIGHT_TEST_HOOKS = saved; // restore the stubbed value
|
||||
}
|
||||
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // ×24, NOT ×1000
|
||||
});
|
||||
|
||||
it('a fully grounded fleet freezes the clock, then resumes with no catch-up (#135)', async () => {
|
||||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → frozen
|
||||
|
||||
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
||||
let sim = await tickOnce(inst);
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no fast-forward, no idle creep
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
||||
|
||||
// A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
|
||||
// A plane takes off: resumes from where it stopped, with NO catch-up for the frozen time.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 60 });
|
||||
await makeWorldActive(inst);
|
||||
vi.setSystemTime(T0 + 3_600_500);
|
||||
@@ -86,6 +109,16 @@ describe('tickOnce — clock advance', () => {
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
|
||||
});
|
||||
|
||||
it('a grounded fleet ignores any raised timeScale — frozen regardless (#135)', async () => {
|
||||
const inst = await freshDb('idle-scale.db');
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
|
||||
|
||||
vi.setSystemTime(T0 + 1_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen, NOT ×1000
|
||||
});
|
||||
|
||||
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
|
||||
const inst = await freshDb('clamp.db');
|
||||
await makeWorldActive(inst);
|
||||
@@ -574,8 +607,12 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
|
||||
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
|
||||
describe('grounded fleet freezes the world clock (#135)', () => {
|
||||
it('pins WORLD_TIME_SCALE = 24 — the single fixed world rate (ISS-117)', () => {
|
||||
expect(WORLD_TIME_SCALE).toBe(24);
|
||||
});
|
||||
|
||||
it('shouldAdvanceTime: grounded/empty fleets are frozen; any flying or repairing plane advances', () => {
|
||||
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||||
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
|
||||
@@ -583,14 +620,14 @@ describe('idle-pause — the world freezes when nothing is happening (ISS-117)',
|
||||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
|
||||
});
|
||||
|
||||
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
|
||||
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
|
||||
it('a fully grounded fleet stays frozen but keeps tracking real time', async () => {
|
||||
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → frozen
|
||||
vi.setSystemTime(T0 + 3_600_000);
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no idle creep
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
|
||||
});
|
||||
|
||||
it('a repairing plane keeps the world advancing', async () => {
|
||||
|
||||
+50
-19
@@ -7,7 +7,9 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
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 { SPECIALS } from '@/lib/game/specials';
|
||||
import { parseIcaoQueue } from '@/lib/game/state';
|
||||
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
|
||||
import { broadcast } from '@/lib/sse';
|
||||
|
||||
import {
|
||||
generatePackage,
|
||||
MARKET_TARGET_OPEN_PACKAGES,
|
||||
@@ -20,9 +22,12 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
|
||||
|
||||
// Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
|
||||
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
|
||||
// same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
|
||||
// per tick so long absences never fast-forward the game; while no plane is flying or in repair the
|
||||
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time.
|
||||
// same locked tickOnce path. Each tick advances by (real ms since last tick) × scale, clamped per
|
||||
// tick so long absences never fast-forward the game; while no plane is flying or in repair the
|
||||
// world clock stays FROZEN (#135 v0.62.0 — final ISS-117 semantics), and lastRealTickMs always
|
||||
// keeps tracking real time, so resuming activity never fast-forwards the frozen span. Scale is the
|
||||
// fixed WORLD_TIME_SCALE in production; only FLIGHT_TEST_HOOKS deployments honor sim.timeScale
|
||||
// (the test-hook override) — legacy saves may hold stale values (#135).
|
||||
|
||||
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
|
||||
// both the client poll interval (~1 s) and the background-tick interval, so active play advances
|
||||
@@ -30,8 +35,10 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
|
||||
// 2 s × scale.
|
||||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
|
||||
// ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home.
|
||||
export { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
// ISS-117: the fixed world rate — imported for tickOnce, re-exported so server code and tests
|
||||
// have one familiar home.
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
export { WORLD_TIME_SCALE };
|
||||
|
||||
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
||||
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
||||
@@ -43,13 +50,15 @@ export function reputationGainFor(km: number): number {
|
||||
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
|
||||
}
|
||||
|
||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the
|
||||
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
||||
// no advance is ever applied twice.
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the same process): each
|
||||
// tick re-reads sim_state after the previous one wrote it, so no advance is ever applied twice.
|
||||
// ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get
|
||||
// separate module instances from Next.js, so a plain `let` would give each layer its own lock and let
|
||||
// their ticks interleave against the shared DB below.
|
||||
const g = globalThis as unknown as { __flightTickChain?: Promise<unknown> };
|
||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = chain.then(fn);
|
||||
chain = run.then(
|
||||
const run = (g.__flightTickChain ??= Promise.resolve()).then(fn);
|
||||
g.__flightTickChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
@@ -58,9 +67,10 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
||||
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
|
||||
|
||||
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock
|
||||
// advances only while something is happening: at least one plane flying OR in repair. A fully
|
||||
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
|
||||
// #135: advance decision — pure, so unit tests feed it synthetic fleets. The world clock runs at
|
||||
// full scale only while something is happening (at least one plane flying OR in repair); a fully
|
||||
// grounded fleet freezes it entirely — no expiry/spawn/repair progress until a dispatch or repair
|
||||
// starts again.
|
||||
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
||||
let flying = 0;
|
||||
let repairing = 0;
|
||||
@@ -77,12 +87,16 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
|
||||
const nowMs = Date.now();
|
||||
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
|
||||
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
|
||||
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress.
|
||||
// lastRealTickMs always tracks real time, so resuming activity never fast-forwards the frozen span.
|
||||
const planeRows = await inst.db.select().from(schema.planes);
|
||||
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
|
||||
// #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale
|
||||
// timeScale (e.g. 1000 from pre-v0.52.0 UI controls, or an old e2e run against the live instance).
|
||||
// Only FLIGHT_TEST_HOOKS deployments honor sim.timeScale, which POST /api/test/set-timescale writes.
|
||||
const scale = process.env.FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE;
|
||||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||||
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
|
||||
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
|
||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||
|
||||
if (advanceMs > 0) {
|
||||
@@ -409,8 +423,25 @@ export async function spawnCoveragePackages(
|
||||
|
||||
// --- 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
|
||||
// subscribers (/api/events). ISS-133 (v0.59.0): frames carry the sseFrame projection — terminal
|
||||
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
||||
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||
// other clients too.
|
||||
async function broadcastState(): Promise<void> {
|
||||
try {
|
||||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
||||
} catch (err) {
|
||||
console.error('flight: state broadcast failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function tickNow(): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => tickOnce(await getGameDb()));
|
||||
return withLock(async () => {
|
||||
const row = await tickOnce(await getGameDb());
|
||||
await broadcastState();
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { broadcast, subscribe } from './sse';
|
||||
|
||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
|
||||
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
||||
// each test only asserts on its own arrays.
|
||||
|
||||
describe('lib/sse', () => {
|
||||
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
|
||||
const a: string[] = [];
|
||||
const b: string[] = [];
|
||||
const unsubA = subscribe((t) => a.push(t));
|
||||
const unsubB = subscribe((t) => b.push(t));
|
||||
|
||||
broadcast('x');
|
||||
expect(a).toEqual(['x']);
|
||||
expect(b).toEqual(['x']);
|
||||
|
||||
unsubA();
|
||||
broadcast('y');
|
||||
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||
expect(b).toEqual(['x', 'y']);
|
||||
unsubB();
|
||||
});
|
||||
|
||||
it('a throwing subscriber does not break the others', () => {
|
||||
const got: string[] = [];
|
||||
subscribe(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
broadcast('z');
|
||||
expect(got).toEqual(['z']);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('unsubscribe is idempotent', () => {
|
||||
const got: string[] = [];
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
unsub();
|
||||
unsub(); // second call must not throw
|
||||
broadcast('w');
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
||||
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
|
||||
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
||||
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
||||
// give each its own empty set and no push would ever be delivered.
|
||||
|
||||
type Push = (text: string) => void;
|
||||
|
||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
||||
|
||||
export function subscribe(fn: Push): () => void {
|
||||
subscribers.add(fn);
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
// Call each subscriber safely — one dead stream must not take the others down.
|
||||
export function broadcast(text: string): void {
|
||||
for (const fn of [...subscribers]) {
|
||||
try {
|
||||
fn(text);
|
||||
} catch {
|
||||
// subscriber's stream is gone; its own cleanup unsubscribes it
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-6
@@ -35,8 +35,14 @@ export async function fetchState(): Promise<StateSnapshot> {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
|
||||
// so every pane renders from the same snapshot instead of running its own poll loops.
|
||||
// The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane
|
||||
// renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary
|
||||
// transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an
|
||||
// automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh
|
||||
// events flow. EventSource reconnects natively, so there is no custom reconnect logic here.
|
||||
const SSE_URL = '/api/events';
|
||||
const SSE_SILENCE_MS = 5_000;
|
||||
|
||||
export function useGameState(intervalMs = 1000): GameStateResult {
|
||||
const [state, setState] = useState<StateSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -70,10 +76,54 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh(); // fetch immediately on mount, don't wait for the first interval tick
|
||||
const id = setInterval(refresh, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs, refresh]);
|
||||
let pollId: ReturnType<typeof setInterval> | null = null;
|
||||
let silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollId !== null) clearInterval(pollId);
|
||||
pollId = null;
|
||||
};
|
||||
// Fallback loop: immediate catch-up fetch, then the old interval.
|
||||
const startPolling = () => {
|
||||
if (closed || pollId !== null) return;
|
||||
refresh();
|
||||
pollId = setInterval(refresh, intervalMs);
|
||||
};
|
||||
|
||||
let source: EventSource | null = null;
|
||||
try {
|
||||
source = new EventSource(SSE_URL);
|
||||
} catch {
|
||||
source = null; // no EventSource support — pure polling below
|
||||
}
|
||||
|
||||
if (source !== null) {
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
applySnapshot(JSON.parse(event.data));
|
||||
} catch {
|
||||
// malformed event — the next one overwrites it anyway
|
||||
}
|
||||
stopPolling(); // fresh push flow → polling no longer needed
|
||||
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
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot)
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
source?.close();
|
||||
stopPolling();
|
||||
if (silenceTimer !== null) clearTimeout(silenceTimer);
|
||||
};
|
||||
}, [intervalMs, refresh, applySnapshot]);
|
||||
|
||||
return { state, loading, error, refresh, applySnapshot };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { WORLD_TIME_SCALE } from './worldClock';
|
||||
|
||||
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
|
||||
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
|
||||
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
|
||||
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE while something is
|
||||
// flying or in repair, and stays frozen at the anchor value while fully grounded (#135).
|
||||
|
||||
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
||||
clockMs: 1_750_000_000_000,
|
||||
@@ -24,10 +25,10 @@ describe('advanceSimClock', () => {
|
||||
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
|
||||
});
|
||||
|
||||
it('is frozen while idle regardless of elapsed time', () => {
|
||||
it('stays exactly at the anchor value while grounded (frozen, #135)', () => {
|
||||
const a = anchor({ advancing: false });
|
||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
|
||||
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — the anchor value
|
||||
expect(advanceSimClock(a, 1_001_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||
});
|
||||
|
||||
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
||||
|
||||
+7
-7
@@ -9,8 +9,8 @@ import { WORLD_TIME_SCALE } from './worldClock';
|
||||
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
||||
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
||||
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
||||
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
|
||||
// server's idle-pause instead of a player-controlled pause.
|
||||
// fixed WORLD_TIME_SCALE; #135 (v0.62.0): while nothing is happening it stays FROZEN at the anchor
|
||||
// value — mirroring the server's freeze, so polls re-anchor without any jump.
|
||||
|
||||
export interface SimClockAnchor {
|
||||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||||
@@ -22,10 +22,10 @@ export interface SimClockAnchor {
|
||||
}
|
||||
|
||||
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||||
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
|
||||
// the anchor value instead of going backwards. Unit-testable in node.
|
||||
// real-time × WORLD_TIME_SCALE; while fully grounded it stays frozen at the anchor value (#135).
|
||||
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||
if (!anchor.advancing) return anchor.clockMs;
|
||||
if (!anchor.advancing) return anchor.clockMs; // frozen while everything is grounded (#135)
|
||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
||||
// (useGameState), so this runs once per poll cycle as well.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
|
||||
// flying or in repair. One line on purpose — keep it in sync with that function.
|
||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
|
||||
// is flying or in repair, frozen otherwise (#135). One line on purpose — keep it in sync.
|
||||
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
|
||||
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
|
||||
export const WORLD_TIME_SCALE = 24;
|
||||
|
||||
// #135 (v0.62.0): a fully grounded fleet FREEZES the world clock — shouldAdvanceTime
|
||||
// (lib/sim/engine.ts) gates every tick and advanceSimClock (lib/useSimClock.ts) holds its anchor
|
||||
// value, so polls re-anchor without any jump. This replaces the v0.56.0 idle slowdown
|
||||
// (IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24): final semantics are ISS-117's — frozen when
|
||||
// everything is grounded, full rate otherwise.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.53.0",
|
||||
"version": "0.63.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//
|
||||
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
|
||||
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
|
||||
// v0.61.0 (ISS-097): additionally keeps every non-large fixed-wing row that is REMOTE — no other
|
||||
// airport within 1000 km of the kept set (lib/airports.ts isRemote) — so Antarctica, the high
|
||||
// Arctic, Alaska, northern Canada and far islands are represented even though they carry no
|
||||
// large_airport; same-station twins collapse via the same 40 km clustering.
|
||||
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
|
||||
//
|
||||
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
|
||||
@@ -14,6 +18,22 @@ import path from 'node:path';
|
||||
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
|
||||
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
|
||||
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
|
||||
// v0.61.0 (ISS-097): mirrors REMOTE_RADIUS_KM in lib/airports.ts — "no other airport within this
|
||||
// radius" is what makes a small airport worth keeping (Antarctica, high Arctic, far islands).
|
||||
const REMOTE_RADIUS_KM = 1000;
|
||||
|
||||
// Same fixed-wing type set as INCLUDED_TYPES in lib/airports.ts (standalone copy — the script must
|
||||
// stay dependency-free): all fixed-wing airports plus seaplane bases.
|
||||
const FIXED_WING_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
||||
|
||||
// Standalone copy of isRemote in lib/airports.ts, for CSV rows: true when `row` has no member of
|
||||
// `others` within REMOTE_RADIUS_KM. Callers pass disjoint sets (the row itself is not in `others`).
|
||||
function isRemoteRow(row, others) {
|
||||
for (const o of others) {
|
||||
if (haversineKm(row, o) <= REMOTE_RADIUS_KM) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
|
||||
function parseCsv(text) {
|
||||
@@ -86,26 +106,33 @@ const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"
|
||||
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
|
||||
|
||||
const allRows = parseCsv(text);
|
||||
const large = allRows.filter((r) => r.type === 'large_airport');
|
||||
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
|
||||
// v0.61.0 (ISS-097): curation runs over ALL fixed-wing rows, not just large_airport — the remote
|
||||
// pass below needs the full pool to test against.
|
||||
const fixed = allRows.filter((r) => FIXED_WING_TYPES.has(r.type));
|
||||
const large = fixed.filter((r) => r.type === 'large_airport');
|
||||
console.log(`downloaded ${allRows.length} rows; ${fixed.length} are fixed-wing, ${large.length} of them large_airport`);
|
||||
|
||||
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
|
||||
// radius, otherwise it starts a new one. Deterministic — same input, same output.
|
||||
const clusters = []; // { anchorIdx (into `large`), members }
|
||||
for (let i = 0; i < large.length; i++) {
|
||||
let joined = false;
|
||||
for (const c of clusters) {
|
||||
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
||||
// radius, otherwise it starts a new one. Deterministic — same input, same output. Returns the
|
||||
// "biggest" member (one airport per cluster) plus the clusters themselves for reporting.
|
||||
function clusterWinners(rows) {
|
||||
const clusters = []; // { anchorIdx (into rows), members }
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let joined = false;
|
||||
for (const c of clusters) {
|
||||
if (haversineKm(rows[i], rows[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
||||
}
|
||||
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
||||
}
|
||||
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
||||
const winners = clusters.map((c) => biggestOf(c.members.map((i) => rows[i])));
|
||||
return { winners, clusters };
|
||||
}
|
||||
|
||||
const kept = [];
|
||||
const { winners: kept, clusters: largeClusters } = clusterWinners(large);
|
||||
const multiClusters = [];
|
||||
for (const c of clusters) {
|
||||
const winner = biggestOf(c.members.map((i) => large[i]));
|
||||
kept.push(winner);
|
||||
for (const c of largeClusters) {
|
||||
if (c.members.length > 1) {
|
||||
const winner = biggestOf(c.members.map((i) => large[i]));
|
||||
multiClusters.push({
|
||||
city: winner.municipality || winner.name,
|
||||
keptIcao: winner.icao_code || winner.ident,
|
||||
@@ -114,17 +141,26 @@ for (const c of clusters) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.61.0 (ISS-097): "special" remote airports — every non-large fixed-wing row with no other
|
||||
// airport within REMOTE_RADIUS_KM of the kept set joins the dataset, so empty regions get their
|
||||
// real (small) airports instead of nothing. Same-station twins (two strips <40 km apart at one
|
||||
// base) collapse via the same greedy clustering as above.
|
||||
const remote = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
|
||||
const { winners: remoteKept } = clusterWinners(remote);
|
||||
|
||||
// --- Guards (fail loud before writing anything) ---
|
||||
const finalRows = [...kept, ...remoteKept];
|
||||
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
|
||||
const keys = new Set(kept.map(key));
|
||||
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
||||
const keys = new Set(finalRows.map(key));
|
||||
if (keys.size !== finalRows.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
||||
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
|
||||
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
|
||||
}
|
||||
if (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); process.exit(1); }
|
||||
if (remoteKept.length === 0) { console.error('no remote airports found in the dump — curation rule broken, aborting'); process.exit(1); }
|
||||
if (finalRows.length < 300 || finalRows.length > 2500) { console.error(`unexpected row count ${finalRows.length}`); process.exit(1); }
|
||||
|
||||
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
||||
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...finalRows.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
||||
|
||||
console.log(`wrote ${OUT_FILE}: ${kept.length} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
|
||||
console.log(`wrote ${OUT_FILE}: ${kept.length} large + ${remoteKept.length} remote = ${finalRows.length} rows (${large.length - kept.length} large dropped into ${multiClusters.length} multi-airport clusters)`);
|
||||
console.log('sample merged cities (kept ← dropped):');
|
||||
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);
|
||||
|
||||
Reference in New Issue
Block a user