Compare commits

..
Author SHA1 Message Date
kamilkrzywda a56937cee2 v0.67.0: prune runner dind leftovers after e2e
CI / build + unit + e2e (pull_request) Successful in 15m40s
2026-09-23 18:05:38 +00:00
kamilkrzywda 4b01c320fa Merge pull request 'v0.66.0 — E2E results in Gitea job summary (#141)' (#142) from ci/e2e-job-summary into master
CI / build + unit + e2e (push) Successful in 14m9s
v0.66.0 — E2E results in Gitea job summary (#141)
2026-09-23 19:29:54 +02:00
kamilkrzywda d278c8ba9c v0.66.0: e2e results in Gitea job summary (#141)
CI / build + unit + e2e (pull_request) Successful in 14m43s
The run page's Job summaries panel showed vitest results (vitest's built-in
github-actions reporter auto-appends $GITHUB_STEP_SUMMARY in CI) but nothing
about the Playwright suite. The E2E step now also runs the built-in json
reporter (--reporter=list,json, output pinned to git-ignored test-results/e2e.json
via PLAYWRIGHT_JSON_OUTPUT_FILE), and a new non-fatal 'E2E job summary' step
(if: always()) parses it with an inline node script and appends spec-level
passed/failed/skipped counts + failed spec names (file > title) to
$GITHUB_STEP_SUMMARY. Guarded so a missing/malformed report can never fail or
mask the e2e step's real exit code; no test behavior, timeouts, workers or
retries changed.
2026-09-23 17:07:23 +00:00
kamilkrzywda 21cfe16d5d Merge pull request 'v0.65.0 — Per-airport job pools with real-time refresh' (#140) from feat/per-airport-jobs into master
CI / build + unit + e2e (push) Successful in 14m16s
2026-09-23 17:46:04 +02:00
kamilkrzywda 70a726d546 fix(e2e): lift zoom floor for antimeridian hover test (EDDF→NFTF spans ~176°)
CI / build + unit + e2e (pull_request) Successful in 14m39s
2026-09-23 15:31:20 +00:00
kamilkrzywda 199704f761 v0.65.0: fix pool comments — queued legs count toward the pool even while their plane is in the shop (startRepair keeps the queue), so an all-repairing fleet only has no pools when its queues are empty too
CI / build + unit + e2e (pull_request) Failing after 16m24s
2026-09-23 13:13:44 +00:00
kamilkrzywda 12d900b22a v0.65.0: per-airport job pools with real-time refresh — open jobs spawn per pool airport (grounded/flying/queued locations) in a fixed POOL_BANDS composition, expire on the wall clock after JOB_LIFETIME_MS even while the world is frozen, and refill taken slots after a per-band cooldown; /api/state + SSE filter open jobs to pool origins and expose expiresRealMs for the right-pane countdown
CI / build + unit + e2e (pull_request) Failing after 17m26s
2026-09-23 13:09:49 +00:00
kamilkrzywda 9c2c64fe89 Merge pull request 'v0.64.0 — Market keeps topping up while the fleet is grounded (#135 follow-up)' (#139) from fix/frozen-market-topup into master
CI / build + unit + e2e (push) Successful in 14m15s
v0.64.0 — Market keeps topping up while the fleet is grounded (#135 follow-up)
2026-09-23 12:22:05 +02:00
kamilkrzywda b2ff1d631b v0.64.0: market top-up + coverage spawn run on every tick including frozen ones, so a grounded fleet can never drain the market to zero (#135 follow-up)
CI / build + unit + e2e (pull_request) Successful in 14m12s
2026-09-23 10:07:28 +00:00
kamilkrzywda d7f352adcf Merge pull request 'v0.60.0 — Fiord default map style (#118)' (#136) from feat/fiord-default-style into master
CI / build + unit + e2e (push) Successful in 13m51s
2026-09-23 10:58:14 +02:00
kamilkrzywda 2b5c8541d7 v0.63.0: retune map overlay colors for the Fiord default (ISS-118) — brighten airport dot fill rose-600→rose-500 and hovered-route highlight red-500→red-400, the two elements below 3:1 on Fiord's dark slate/navy palette; all other overlay colors audited ≥3.3:1 and left untouched
CI / build + unit + e2e (pull_request) Successful in 13m55s
2026-09-23 08:43:41 +00:00
kamilkrzywda 673365f49c Merge pull request 'v0.62.0 — Fixed 24x world clock (#135)' (#138) from feat/fixed-world-clock into master
CI / build + unit + e2e (push) Successful in 17m9s
2026-09-23 10:24:07 +02:00
kamilkrzywda bb4e3b96f3 v0.62.0: fixed 24x world clock — frozen when fully grounded (#135)
CI / build + unit + e2e (pull_request) Successful in 14m3s
2026-09-23 08:09:29 +00:00
kamilkrzywda e970f95036 Merge pull request 'v0.61.0 — Special remote airports (#97)' (#137) from feat/special-airports into master
CI / build + unit + e2e (push) Successful in 16m38s
2026-09-23 09:42:12 +02:00
kamilkrzywda 03731b938e Special remote airports + isRemote curation (ISS-097, v0.61.0)
CI / build + unit + e2e (pull_request) Successful in 15m31s
2026-09-23 07:14:35 +00:00
kamilkrzywda 1d9e9ade58 Merge pull request 'v0.59.0 — SSE frames drop terminal package history (#133)' (#134) from feat/sse-payload into master
CI / build + unit + e2e (push) Successful in 14m9s
2026-09-23 08:06:44 +02:00
kamilkrzywda 6a819b8d5a v0.59.0: SSE frames drop terminal package history (sseFrame projection) — ~597 KB → ~10 KB per frame (#133)
CI / build + unit + e2e (pull_request) Successful in 17m7s
2026-09-23 05:49:17 +00:00
kamilkrzywda c5413aff65 Merge pull request 'v0.58.0 — plane markers no longer teleport across the antimeridian (#124)' (#132) from feat/antimeridian-markers into master
CI / build + unit + e2e (push) Successful in 16m47s
2026-09-23 07:32:39 +02:00
kamilkrzywda 44996b434f e2e(antimeridian): crossing assertion copy-agnostic (|lon| > 180) — run-180 proved the product correct: sampled lons [−184.75…−175.24] are a continuous western world copy, only the 'east copy' assumption was wrong
CI / build + unit + e2e (pull_request) Successful in 15m24s
2026-09-23 05:17:11 +00:00
kamilkrzywda d0d764e0c1 e2e(antimeridian): run-179 data collection — unconditional server/source diagnostics, values in expect messages, and wait-for-flying now requires SERVER state on the specific ORIGIN->CROSS_DEST leg (marker-only poll could pass on a stale hop frame)
CI / build + unit + e2e (pull_request) Failing after 18m58s
2026-09-23 02:48:32 +00:00
kamilkrzywda d0aec35db1 fix(useGameState): re-arm SSE silence check on every message — a stream that goes silent after the first event must fall back to polling again (run-178 root cause); unit: pin NZAA→NFTF full-pipeline marker lons on stored dataset coords
CI / build + unit + e2e (pull_request) Failing after 19m22s
2026-09-23 01:59:54 +00:00
kamilkrzywda 7671c028b4 e2e: reload before the crossing leg + freshness gate on sampled frames (run-177); queue refuel bound 1.4→1.3×required2
CI / build + unit + e2e (pull_request) Failing after 20m42s
2026-09-23 00:32:36 +00:00
kamilkrzywda baa4b0fa7c e2e: fit camera over the crossing leg before querying the planes source; cleanup always runs (run-176)
CI / build + unit + e2e (pull_request) Failing after 21m24s
2026-09-22 22:58:03 +00:00
kamilkrzywda c06e038130 v0.58.0 — plane markers no longer teleport across the antimeridian (ISS-124)
CI / build + unit + e2e (pull_request) Failing after 8m36s
planesGeoJson now takes the airport index and unwraps each flying plane's
longitude against its departure airport (unwrapLon, shared with unwrapLons),
mirroring routesGeoJson's both-endpoints guard so a marker is unwrapped iff
its line is drawn. Map.tsx passes airportsIndexRef at both call sites.

Unit +6 in lib/mapData.test.ts; e2e +1 in e2e/map-interactions.spec.ts
(chain-relocate to NZAA, fly the NZAA→NFTF crossing, sample marker lons).
2026-09-22 21:35:48 +00:00
kamilkrzywda 18e5c832e3 Merge pull request 'v0.57.0 — push updated state to clients over SSE (#129)' (#131) from feat/sse-state-push into master
CI / build + unit + e2e (push) Successful in 11m33s
2026-09-22 22:55:42 +02:00
kamilkrzywda 1983d17099 push updated state to clients over SSE (ISS-129)
CI / build + unit + e2e (pull_request) Successful in 11m7s
2026-09-22 20:44:13 +00:00
kamilkrzywda 9b2938a5b0 Merge pull request 'v0.56.0 — idle fleet slows world clock to real-time instead of freezing (#128)' (#130) from feat/idle-slowdown into master
CI / build + unit + e2e (push) Failing after 10m12s
2026-09-22 20:48:17 +02:00
kamilkrzywda 0a40143497 idle fleet slows world clock to real time instead of freezing (ISS-128)
CI / build + unit + e2e (pull_request) Successful in 10m32s
2026-09-22 18:37:06 +00:00
kamilkrzywda 5268a39fe9 Merge pull request 'v0.55.0 — add favicon (#122)' (#126) from feat/favicon into master
CI / build + unit + e2e (push) Successful in 9m52s
2026-09-22 19:39:50 +02:00
kamilkrzywda ae91bf6a5d harden e2e getState against transient /api/state 500s (ISS-127)
CI / build + unit + e2e (pull_request) Successful in 9m55s
2026-09-22 17:29:45 +00:00
kamilkrzywda a8c4e93dc1 fix e2e: actions.spec.ts self-heal tops tank full after reposition (retry cascade after transient 500)
CI / build + unit + e2e (pull_request) Failing after 9m33s
2026-09-22 16:42:38 +00:00
kamilkrzywda e5693b3301 fix e2e: favicon href hash query + hover self-heal
CI / build + unit + e2e (pull_request) Failing after 10m50s
2026-09-22 16:03:21 +00:00
kamilkrzywda dae354410f Add favicon: white airplane on pastel blue as app/icon.svg (ISS-122)
CI / build + unit + e2e (pull_request) Failing after 12m56s
2026-09-22 15:39:04 +00:00
kamilkrzywda d53ad41c09 Merge pull request 'v0.54.0 — long priority jobs are now always finishable (#119)' (#125) from feat/expiry-floor into master
CI / build + unit + e2e (push) Failing after 8m47s
2026-09-22 17:31:51 +02:00
kamilkrzywda 82061ed869 Clamp generated package expiry to travel time at max catalog speed + buffer so long jobs are finishable (ISS-119)
CI / build + unit + e2e (pull_request) Successful in 9m43s
2026-09-22 15:21:50 +00:00
kamilkrzywda 2a4b341a9c Merge pull request 'v0.53.0 — antimeridian routes no longer chord across the map (#120)' (#123) from feat/antimeridian-route into master
CI / build + unit + e2e (push) Successful in 12m43s
2026-09-22 17:00:15 +02:00
kamilkrzywda cd2c766895 Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120)
CI / build + unit + e2e (pull_request) Successful in 10m58s
2026-09-22 14:48:53 +00:00
kamilkrzywda 23896b2cc4 Merge pull request 'v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)' (#121) from feat/fixed-world-clock into master
CI / build + unit + e2e (push) Successful in 12m28s
v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)
2026-09-22 16:13:41 +02:00
kamilkrzywda 189a2c04eb Fix e2e specs for fixed world clock: idle-pause expiry window, scale-1 motion settle, self-heal (ISS-117)
CI / build + unit + e2e (pull_request) Successful in 12m44s
2026-09-22 14:00:53 +00:00
kamilkrzywda a0560ec767 Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
  lib/timeScale.ts, LeftPane pause button + scale slider machinery,
  ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
  rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
  (sibling of set-health); all e2e setScale helpers call it, afterEach restores
  the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
  activates the world via set-health + /repair for advance/expiry tests.
2026-09-22 10:50:21 +00:00
kamilkrzywda d31bc5d60f Merge pull request 'v0.51.0 — spec card shows guaranteed (worst-case headwind) max range (#99)' (#104) from feat/fuel-range-consistency into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 11:10:04 +02:00
kamilkrzywda 8319b2fa78 Max range display is the guaranteed (worst-case headwind) full-tank range (ISS-099, v0.51.0)
CI / build + unit + e2e (pull_request) Successful in 12m19s
2026-09-22 08:57:09 +00:00
kamilkrzywda f01c3f417c Merge pull request 'v0.50.0 — queued/planned route dotted line yellow-ish instead of gray (#98)' (#103) from feat/queued-route-color into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 10:29:13 +02:00
kamilkrzywda c3339f0e40 Make queued/planned route dotted line amber/gold instead of gray (ISS-098)
CI / build + unit + e2e (pull_request) Successful in 10m51s
Extract the queue-line color from an inline literal to exported
QUEUE_ROUTE_LINE_COLOR and retune it #64748b -> #d4a017. Dashed style
and width unchanged. Unit test pins the constant; existing e2e specs
(queue.spec.ts, relocation.spec.ts) already cover the rendering path.
2026-09-22 08:02:10 +00:00
kamilkrzywda d215203988 Merge pull request 'v0.49.0 — airport popup Relocate works for flying planes via queue (#96)' (#102) from feat/relocate-queue into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 09:48:50 +02:00
kamilkrzywda 11fc6f9d5c Airport popup Relocate works for flying/repairing planes via queue (ISS-096, v0.49.0)
CI / build + unit + e2e (pull_request) Successful in 12m56s
The airport popup's Relocate row now lists ALL planes instead of only
grounded ones; the click handler picks the endpoint by the selected
plane's current status — available → /fly (unchanged), flying/repairing
→ /queue so the destination is appended to the flight queue and chains
on landing. No-planes fallback reworded to 'No planes to relocate.'

E2E +1: relocation.spec.ts dispatches the starter on a leg via API,
slows the sim to 1x, opens the popup for a different airport, selects
the flying plane and clicks Relocate here — asserts the queue contains
the clicked ICAO while the plane still flies its original destination.
2026-09-22 07:32:31 +00:00
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
76 changed files with 3921 additions and 1926 deletions
+58 -2
View File
@@ -189,10 +189,56 @@ jobs:
# in-network app host out of any proxy path.
NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl
no_proxy: app,localhost,127.0.0.1,.kkhost.pl
# Machine-readable e2e results for the job-summary step below (Playwright's built-in
# json reporter; without this env var it would dump to stdout). test-results/ is git-ignored.
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/e2e.json
# Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
# visibility in the job log; failure error context/snapshots are unchanged.
run: npx playwright test --reporter=list
# visibility in the job log; failure error context/snapshots are unchanged. The json
# reporter adds a machine-readable report (PLAYWRIGHT_JSON_OUTPUT_FILE above) — no
# execution behavior changes, reporters only observe.
run: npx playwright test --reporter=list,json
# Appends e2e results to the job-summary panel — mirrors how unit tests get there
# (vitest's built-in github-actions reporter auto-appends $GITHUB_STEP_SUMMARY in CI).
# if: always() so a FAILED suite still gets summarized; non-fatal by design — a missing
# or malformed report must never mask the real e2e exit code. The script is single-quoted
# for bash, so it contains no apostrophes.
- name: E2E job summary
if: always()
run: |
node -e '
const fs = require("fs");
let report;
try {
report = JSON.parse(fs.readFileSync("test-results/e2e.json", "utf8"));
} catch {
process.exit(0); // e2e never ran (earlier step failed) or no report — nothing to summarize
}
let passed = 0, skipped = 0;
const failed = [];
const walk = (suite) => {
for (const spec of suite.specs ?? []) {
if (!spec.ok) failed.push(`${suite.title} › ${spec.title}`);
else if ((spec.tests ?? []).length && spec.tests.every((t) => t.expectedStatus === "skipped")) skipped++;
else passed++;
}
for (const sub of suite.suites ?? []) walk(sub);
};
for (const s of report.suites ?? []) walk(s);
const lines = [
"", "## E2E results", "",
`- Passed: ${passed}`,
`- Failed: ${failed.length}`,
`- Skipped: ${skipped}`,
];
if (failed.length) {
lines.push("", "### Failed specs");
for (const f of failed) lines.push(`- \`${f}\``);
}
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (summaryFile) fs.appendFileSync(summaryFile, lines.join("\n") + "\n");
' || true
# The daemon is shared with production — never leave our stack or network behind.
# -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
@@ -201,3 +247,13 @@ jobs:
run: |
docker compose -f docker-compose.ci.yml -p flight-ci down --remove-orphans -v || true
docker network remove flight-ci-e2e 2>/dev/null || true
# The shared runner dind still accumulates between runs what the steps above can't reach:
# dangling images/layers from the per-run flight-ops-ci rebuilds, BuildKit cache, and job
# containers leaked by interrupted runs. Prune them here (after stack + network cleanup).
# Deliberately NO --volumes: the daemon is shared with production and named volumes may hold
# other workflows' persistent data — this project's own flight-ci_flight-db volume is already
# dropped by the steps above. Non-fatal so a prune failure can never mask or break the run.
- name: Prune runner dind leftovers
if: always()
run: docker system prune -af || echo "::warning::dind prune failed — see logs"
+98
View File
@@ -1,5 +1,103 @@
# Changelog
## v0.67.0 — 2026-09-23 18:03
- **Prune runner dind leftovers after e2e.** The CI runner's full-dind daemon is shared with production and other workflows, so between runs it accumulates what the existing cleanup steps can't reach: dangling images/layers from the per-run `flight-ops-ci` rebuilds, BuildKit cache (persists by design), and job containers leaked by interrupted runs. A new non-fatal "Prune runner dind leftovers" step (`if: always()`, placed after the existing "Cleanup e2e stack + network") runs `docker system prune -af || echo "::warning::dind prune failed — see logs"` so a prune failure can never mask or break the run's real result. Deliberately NO `--volumes`: named volumes on the shared daemon may hold other workflows' persistent data — this project's own `flight-ci_flight-db` volume is already dropped by the existing cleanup steps. No app/TS changes; CI validates the workflow itself.
## v0.66.0 — 2026-09-23 17:06
- **E2E results in the Gitea job summary (#141).** The run page's "Job summaries" panel showed vitest results (vitest's built-in `github-actions` reporter auto-appends `$GITHUB_STEP_SUMMARY` when `GITHUB_ACTIONS=true`) but nothing about the Playwright suite, so a failed e2e run required digging into raw logs to see which specs broke. The CI E2E step now also runs Playwright's built-in `json` reporter (`--reporter=list,json`, output pinned to git-ignored `test-results/e2e.json` via `PLAYWRIGHT_JSON_OUTPUT_FILE` — no new dependencies, no config/TS changes), and a new non-fatal "E2E job summary" step (`if: always()`) parses that report with an inline node script and appends spec-level passed/failed/skipped counts plus the file + title of every failed spec to `$GITHUB_STEP_SUMMARY` — mirroring exactly how vitest gets there. The append is guarded (`|| true`, missing/malformed report = silent no-op) so it can never fail or mask the e2e step's real exit code; test behavior, timeouts, workers and retries are untouched.
## v0.65.0 — 2026-09-23 12:58
- **Per-airport job pools with real-time refresh.** The market was a global bag of jobs (top-up to `MARKET_TARGET_OPEN_PACKAGES = 10` + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' `locationIcao`, flying planes' `destIcao` and queued-leg ICAOs — queued legs count even while their plane is in the shop (the work will be waiting at its next stop); a fleet fully repairing with empty queues has no pool, and orphaned pools simply expire out. Each pool airport holds exactly `POOL_TARGET_PER_AIRPORT = 10` open jobs in a fixed weight composition (`POOL_BANDS`: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. `spawnPoolPackages` in `lib/sim/engine.ts` replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new `generatePoolPackage` helper in `lib/game/seed.ts`, so they are indistinguishable — `buildSeed` now seeds a full pool at the starter's home hub, and `PACKAGE_COUNT_MIN/MAX` + `MARKET_TARGET_OPEN_PACKAGES` are retired.
- **Open jobs live on the wall clock.** New `created_real_ms INTEGER NOT NULL DEFAULT 0` column (guarded ALTER backfilled with `Date.now()` for existing rows): an open job expires `JOB_LIFETIME_MS = 1 h` after creation regardless of time scale or fleet state — checked every tick OUTSIDE the advance gate, so a paused world still refreshes its board. Loaded jobs keep their sim-clock deadline logic unchanged (delivery check, expiry floor, abandon penalty all untouched). Taken slots (loaded OR ignored) stamp `slot_free_at_real_ms` and hold their pool slot for a refill cooldown — `REFILL_DELAY_MS = 15 min`, or `SMALL_REFILL_DELAY_MS = 1 min` for the <4 t band — so the board visibly refreshes instead of instantly refilling; expired/delivered/abandoned never block. ponytail: one shared per-airport cooldown (the max stamp) rather than a per-band table, add when per-band refill timing matters.
- **Visibility + UI.** `/api/state` and SSE now filter open packages to pool origins (`poolAirports` in `lib/game/state.ts`; all non-open statuses pass through unchanged), so the right pane only ever shows jobs you can take — no disabled Load buttons for foreign-origin work. `PackageState` gains `expiresRealMs` (open → `createdRealMs + JOB_LIFETIME_MS`, else null; OpenAPI updated) and the open-job row renders a wall-clock countdown via the existing pure `formatDeadlineRelative` formatter (no new timer — the ~1 s poll re-renders anyway); loaded jobs keep their sim-deadline display.
- **Test hooks.** `/api/test/expire-package` rewritten for the wall clock: `{packageId}` ages an OPEN job's `createdRealMs` past its lifetime (`HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000`) and runs a tick, so it comes back already expired (200; unknown/non-open ids 404); no body creates one already-aged hub job (201). CI env: `docker-compose.ci.yml` sets `FLIGHT_JOB_REFILL_MS=5000` + `FLIGHT_JOB_SMALL_REFILL_MS=2000` under the existing `FLIGHT_TEST_HOOKS=1` gate — the lifetime stays 1 h everywhere on purpose, so `state.spec.ts`'s mid-suite container restart can't race a whole board expiring.
- **Tests.** Unit: `lib/sim/engine.test.ts` rewritten around the new functions — `expireLoadedPackages (sim clock)` and `expireOpenPackages (v0.65.0 wall clock)` suites (both with inclusive-boundary edges), the frozen-top-up test now asserts exactly 10 open jobs all at EDDF, and a six-test `spawnPoolPackages` suite pins band composition, determinism under an injected RNG + wall clock, pool membership for flying/queued airports (departure airport excluded while its plane is airborne), the taken-slot cooldown ladder, and the full tickOnce pipeline. `lib/game/seed.test.ts`: buildSeed now asserts the exact hub-pool composition (10 jobs, band counts, weights in-band, `createdRealMs` = seed moment) plus a "board always offers jobs beyond the starter" pin; the replay test mirrors `generatePoolPackage`; five full-object equality tests pin `createdRealMs` to avoid millisecond-boundary flakes. E2E: `e2e/sim.spec.ts`'s expiry tests are wall-clock now (hook ages + ticks, then polls the pool back to target — no scale or clock activation); `e2e/right-pane.spec.ts` replaces the LEMD Load-disabled test with an absence assertion (a non-pool-origin open job never reaches `/api/state`); `e2e/hover.spec.ts`'s antimeridian pair moves NZAA→NFTF to EDDF→NFTF (same ±180 eastward crossing, sampled maxJump 359.98) because the hook must create jobs at a pool airport; `e2e/expiry.spec.ts` pins the new hook contract (aged job expired by response time, unknown id 404, already-expired 404).
## v0.64.0 — 2026-09-23 10:06
- **Market keeps topping up while the fleet is grounded (#135 follow-up).** Root cause of a soft-lock introduced by v0.62.0's frozen clock: in `tickOnce` (`lib/sim/engine.ts`) every package-CREATION path (market top-up + coverage spawn) sat behind the same `advanceMs > 0` gate as expiry/delivery/repairs, while removal paths did not — `ignorePackage` runs on any request regardless of advance. A fully grounded fleet therefore froze the clock AND stopped all spawning, so repeated ignores could drain the market below `MARKET_TARGET_OPEN_PACKAGES = 10` (down to zero) and no code path ever created new jobs again: a player with a grounded fleet and an empty market was stuck forever. Fix: only the two spawn calls moved OUT of the gate — `spawnOpenPackages` + `spawnCoveragePackages` now run on EVERY tick, including frozen ones, passing the current (possibly frozen) `clockMs`; `expirePackages`, `deliverPackages` and `finishRepairs` stay inside it. Both spawns were already idempotent no-ops when satisfied (count-gated at target / set-gated by uncovered grounded airports), so advancing ticks behave exactly as before and the clock stays EXACTLY frozen while grounded — only new packages appear, created at game time. No schema change, no migration, no other engine behavior touched.
- **Tests.** Unit: `lib/sim/engine.test.ts` — "a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)": fully grounded fleet (advance=false), open market drained to zero via direct DB writes, one tick → asserts BOTH that `sim.clockMs` is unchanged (freeze preserved) AND that the open count is back at ≥ `MARKET_TARGET_OPEN_PACKAGES` with ≥1 open package originating at the grounded starter's airport (EDDF coverage). E2E: `e2e/sim.spec.ts` — "a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)": self-heals to a fully grounded fleet, drains by calling `POST /api/packages/[id]/ignore` for every open package in repeated passes (pre-fix this reached zero and stayed there; post-fix each request's tick re-tops-up before its removal, so it can't), then `expect.poll`s that the open count from `/api/state` is above 0 within ~5 s of real time while asserting `simState.clockMs` stays EXACTLY equal to its pre-drain value and no plane took off — pinning both starvation-fixed and clock-still-frozen.
## 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.
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
## v0.52.0 — 2026-09-22 10:45
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve).
## v0.51.0 — 2026-09-22 08:21
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
## v0.50.0 — 2026-09-22 08:00
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
## v0.49.0 — 2026-09-22 07:25
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
## v0.47.0 — 2026-09-21 23:01
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
+41
View File
@@ -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
}
});
});
+68
View File
@@ -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
},
});
}
+2 -2
View File
@@ -57,7 +57,7 @@ describe('POST /api/packages/:id/abandon', () => {
const { db } = await getGameDb();
await db.insert(schema.packages).values({
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — stay open past the pre-action tick
});
const res = await post('p-open');
@@ -94,7 +94,7 @@ describe('POST /api/packages/:id/abandon', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117
packages: Array<{ id: string; status: string; planeId: string | null }>;
};
expect(Object.keys(body).sort()).toEqual([
+4 -4
View File
@@ -45,7 +45,7 @@ describe('POST /api/packages/:id/load', () => {
const { db } = await getGameDb();
await db.insert(schema.packages).values({
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-ghost-plane', { planeId: 'ghost' });
@@ -57,7 +57,7 @@ describe('POST /api/packages/:id/load', () => {
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
await db.insert(schema.packages).values({
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
@@ -82,7 +82,7 @@ describe('POST /api/packages/:id/load', () => {
// A known open package originating where the starter actually sits (EDDF).
await db.insert(schema.packages).values({
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-home', { planeId: 'plane-starter' });
@@ -92,7 +92,7 @@ describe('POST /api/packages/:id/load', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117
planes: Array<Record<string, unknown>>;
packages: Array<{ id: string; status: string; planeId: string | null }>;
};
+1 -1
View File
@@ -84,7 +84,7 @@ describe('POST /api/planes/:planeId/fly', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
};
expect(Object.keys(body).sort()).toEqual([
-116
View File
@@ -1,116 +0,0 @@
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 { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { POST } from './route';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) }));
const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text }));
describe('POST /api/sim', () => {
it('returns the current simState for an empty body (tick + no-op)', async () => {
const res = await post({});
expect(res.status).toBe(200);
const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean };
expect(typeof body.clockMs).toBe('number');
expect(body.timeScale).toBe(1); // seeded default
expect(body.paused).toBe(false);
});
it('sets and clamps timeScale into [1, 1000]', async () => {
const hi = await post({ timeScale: 2000 });
expect(hi.status).toBe(200);
expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000);
const lo = await post({ timeScale: 0.1 });
expect(lo.status).toBe(200);
expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1);
// The top of the ladder passes through unclamped (v0.14.0).
const max = await post({ timeScale: 1000 });
expect(max.status).toBe(200);
expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000);
// In-range values pass through unclamped — ladder and off-ladder alike.
const ladder = await post({ timeScale: 500 });
expect(ladder.status).toBe(200);
expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500);
const mid = await post({ timeScale: 37 });
expect(mid.status).toBe(200);
expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37);
// Restore the default for later tests.
await post({ timeScale: 1 });
});
it('toggles paused and freezes the clock across ticks', async () => {
const pause = await post({ paused: true });
expect(pause.status).toBe(200);
expect(((await pause.json()) as { paused: boolean }).paused).toBe(true);
// Two reads while paused → identical clock (tick-on-read advances nothing).
const a = await post({});
const b = await post({});
expect(((await a.json()) as { clockMs: number }).clockMs).toBe(
((await b.json()) as { clockMs: number }).clockMs,
);
const resume = await post({ paused: false });
expect(resume.status).toBe(200);
expect(((await resume.json()) as { paused: boolean }).paused).toBe(false);
});
it('applies both fields in one call', async () => {
const res = await post({ timeScale: 64, paused: true });
expect(res.status).toBe(200);
const body = (await res.json()) as { timeScale: number; paused: boolean };
expect(body.timeScale).toBe(64);
expect(body.paused).toBe(true);
await post({ timeScale: 1, paused: false }); // restore
});
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
const row = async () => (await inst.db.select().from(schema.simState))[0];
await post({ timeScale: 250 });
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
await post({ paused: true });
expect((await row()).paused).toBe(true);
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
await post({ timeScale: 1, paused: false }); // restore
expect((await row()).lastSelectedTimeScale).toBe(1);
});
it('rejects invalid input with 400', async () => {
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
for (const text of ['nope{', '[1,2]', '"str"', 'null']) {
const res = await postRaw(text);
expect(res.status).toBe(400);
}
});
});
-42
View File
@@ -1,42 +0,0 @@
import { applyTimeControls } from '@/lib/sim/engine';
// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }.
// Ticks the clock first (so the response reflects current game time), then applies
// and persists the new controls. Returns the updated simState.
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'timeScale' && key !== 'paused') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) {
return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 });
}
if (input.paused !== undefined && typeof input.paused !== 'boolean') {
return Response.json({ error: 'paused must be a boolean' }, { status: 400 });
}
try {
const sim = await applyTimeControls({
timeScale: input.timeScale as number | undefined,
paused: input.paused as boolean | undefined,
});
return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused });
} catch (err) {
console.error('failed to apply sim controls', err);
return Response.json({ error: 'failed to apply sim controls' }, { status: 500 });
}
}
+15 -4
View File
@@ -2,6 +2,8 @@ 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 { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route';
@@ -41,10 +43,8 @@ describe('GET /api/state', () => {
expect(typeof account.cash).toBe('number');
expect(typeof account.reputation).toBe('number');
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean };
const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
expect(typeof sim.clockMs).toBe('number');
expect(typeof sim.timeScale).toBe('number');
expect(sim.paused).toBe(false);
const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1);
@@ -58,9 +58,20 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key);
}
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
// fixture airports' coordinates for every package in the snapshot.
const fc = await loadAirportsGeoJson();
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
for (const pkg of packages) {
const from = coords.get(String(pkg.originIcao));
const to = coords.get(String(pkg.destIcao));
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
}
});
it('is stable across repeated calls (same seeded DB)', async () => {
+24 -15
View File
@@ -3,21 +3,22 @@ import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed';
import { generatePackage, HUB_ICAO, JOB_LIFETIME_MS, mulberry32 } from '@/lib/game/seed';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite
// observe package expiry without waiting hours of game time. Disabled unless
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance
// only; every other deployment 404s here.
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0; reworked for wall-clock expiry in v0.65.0) —
// lets the e2e suite observe open-job expiry without waiting an hour of real time. Disabled unless
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance only; every other
// deployment 404s here.
//
// POST /api/test/expire-package
// body { packageId?: string }
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS
// (game time) so the next fast tick expires it; 404 if unknown/not open.
// - without: creates a fresh open package with the same short deadline.
// - with packageId: ages that OPEN package's createdRealMs to HOOK_AGE_MS in the past so the next
// tick (run immediately via tickNow) flips it to 'expired'; 404 if unknown/not open.
// - without: creates a fresh open package at the home hub, already aged past its lifetime — visible
// while open (the hub is always in the pool), expired by the time the caller polls /api/state.
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (≈ 0.01 s real at the fastest 1000×)
const HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000; // comfortably past the job lifetime
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
@@ -41,8 +42,6 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before we set the deadline
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
if (typeof packageId === 'string') {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
@@ -50,8 +49,11 @@ export async function POST(req: Request) {
if (!row || row.status !== 'open') {
return Response.json({ error: 'package not found or not open' }, { status: 404 });
}
await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId));
await inst.db.update(schema.packages).set({ createdRealMs: Date.now() - HOOK_AGE_MS }).where(eq(schema.packages.id, packageId));
await persist(inst);
// AFTER the mutation on purpose: tickNow runs a full tickOnce, so expireOpenPackages flips the
// aged row to 'expired' within this same call — callers can poll /api/state immediately.
await tickNow();
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
return Response.json(updated ?? row);
}
@@ -64,11 +66,18 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
// special: null — the hook never rolls a special status (v0.37.0).
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
// nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs
// is real time. special: null — the hook never rolls a special status (v0.37.0). Origin forced to
// the home hub so the job is visible while open; already aged past its lifetime, it expires on
// the tick below.
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, originIcao: HUB_ICAO, special: null, createdRealMs: Date.now() - HOOK_AGE_MS });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
await tickNow();
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, created.id)))[0];
return Response.json(updated ?? created, { status: 201 });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+54
View File
@@ -0,0 +1,54 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
// stays for relative grants, this one is exact.
//
// POST /api/test/set-cash
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
// account.cash to exactly that value and returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const amount = input.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { setTestTimeScale } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now
// that there are no player-facing controls; without it every spec would run at the fixed world
// rate and a cross-Europe leg would take hours of real time. Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/set-health — same gate, same conventions.
//
// POST /api/test/set-timescale
// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise).
// Ticks first so the write lands on current game state, under the sim lock.
// Returns { clockMs, timeScale } — the new effective scale for test assertions.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'timeScale') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const timeScale = input.timeScale;
if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) {
return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 });
}
try {
const row = await setTestTimeScale(timeScale);
return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+4
View File
@@ -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

+2 -2
View File
@@ -16,7 +16,7 @@ export default function Home() {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately.
const { state, loading, error, refresh, applySnapshot } = useGameState(1000);
const { state, loading, error, applySnapshot } = useGameState(1000);
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
// either pane highlights the matching plane/route/airports on the map.
@@ -45,7 +45,7 @@ export default function Home() {
<div className="app-shell">
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
fly/abandon actions push their response snapshot into the shared state */}
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<LeftPane state={state} loading={loading} error={error} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */}
+13 -219
View File
@@ -1,12 +1,11 @@
'use client';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
import { cargoWeightFor } from './RightPane';
@@ -16,8 +15,6 @@ interface LeftPaneProps {
state: StateSnapshot | null;
loading: boolean;
error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void;
@@ -27,12 +24,6 @@ interface LeftPaneProps {
onError?: (message: string) => void;
}
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
// roughly doubles the speed.
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
const SCALE_COMMIT_DEBOUNCE_MS = 250;
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
function planeLocation(p: PlaneState): string {
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
@@ -47,51 +38,7 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
}
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
return pendingScale !== null && serverTimeScale === pendingScale;
}
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
export const SIM_POST_MAX_ATTEMPTS = 3;
export const SIM_POST_RETRY_DELAY_MS = 500;
export type SimPostRetryResult =
| { ok: true }
| { ok: false; reason: 'exhausted'; error: unknown }
| { ok: false; reason: 'superseded' };
export async function retrySimPost(
post: () => Promise<void>,
superseded: () => boolean,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
delayMs: number = SIM_POST_RETRY_DELAY_MS,
): Promise<SimPostRetryResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
try {
await post();
return { ok: true };
} catch (err) {
lastError = err;
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
if (attempt < maxAttempts) await sleep(delayMs);
}
}
return { ok: false, reason: 'exhausted', error: lastError };
}
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
@@ -123,73 +70,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
}, []);
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
// toast (the running poll retries on its own). A commit that lands while another POST is in
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
// which used to let a flush re-queue instead of send and silently drop a commit.
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
const busyRef = useRef(false);
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
// 'queued': an in-flight call holds it — its flush will send it.
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
const applySimControls = useCallback(
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
if (busyRef.current) {
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
return 'queued';
}
busyRef.current = true;
setBusy(true);
let sentOwn = false;
try {
let current: { timeScale?: number; paused?: boolean } | null = body;
while (current !== null) {
const target = current; // stable across this retry round
const result = await retrySimPost(
async () => {
const res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
},
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
);
if (result.ok) {
onRefresh();
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
} else if (result.reason === 'exhausted') {
throw result.error; // only after the final attempt fails → error toast below
}
current = queuedRef.current; // pick up anything that arrived while we were in flight
queuedRef.current = null;
}
} catch (err) {
console.error('failed to apply sim controls', err);
// ISS-008: surface the failure as an error toast too — previously console-only.
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
setBusy(false);
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh, onError],
);
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
const clockMs = state?.simState.clockMs ?? 0;
@@ -208,69 +88,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
relocatePlane?.status === 'flying' && relocateDest !== null &&
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
// user releases the slider (browsers fire a native 'change' on release). The server's
// [1,1000] clamp stays as the backstop.
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
const pendingRef = useRef<number | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setPending = useCallback((s: number | null) => {
pendingRef.current = s;
setPendingScaleState(s);
}, []);
const commitNow = useCallback(() => {
if (commitTimerRef.current !== null) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
const s = pendingRef.current;
if (s === null) return;
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
// call flushes it); only a genuine failure drops it so the display falls back to truth.
void applySimControls({ timeScale: s }).then((outcome) => {
if (outcome === 'failed') setPending(null);
});
}, [applySimControls, setPending]);
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
// flight, the optimistic value has been superseded by server truth — clear it (display is
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
// quick drag back to the previous scale must not be swallowed before its own commit fires).
useEffect(() => {
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
}, [state, pendingScale, busy, setPending]);
const onSliderInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setPending(sliderToScale(Number(e.target.value)));
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
},
[commitNow, setPending],
);
const sliderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = sliderRef.current;
if (!el) return;
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
return () => el.removeEventListener('change', commitNow);
}, [commitNow]);
// Drop any pending debounce timer on unmount.
useEffect(
() => () => {
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
},
[],
);
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
// in an effect after hydration, so server and client render identically) and follows the
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
@@ -290,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
? 'Notifications unavailable'
: 'Enable notifications';
const displayedScale = pendingScale ?? timeScale;
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
// penalty previews stay snapshot-based (deterministic against the server clock).
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
// ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
// paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
// the server clock).
const simClockMs = useSimClock(state);
return (
<aside data-testid="left-pane" className="pane left-pane">
@@ -441,6 +256,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao}
</span>
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
@@ -561,32 +378,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section">
<h3 className="pane-heading">Time</h3>
<p data-testid="sim-clock" className="sim-clock">
{simClockMs !== null && formatSimClock(simClockMs)}
{paused ? ' (paused)' : ''}
</p>
<div className="time-controls">
<button
type="button"
data-testid="pause-toggle"
disabled={busy}
onClick={() => void applySimControls({ paused: !paused })}
>
{paused ? 'Resume' : 'Pause'}
</button>
<input
ref={sliderRef}
type="range"
data-testid="time-scale-slider"
aria-label="Time scale"
min={0}
max={1}
step={SCALE_SLIDER_STEP}
value={scaleToSlider(displayedScale)}
onChange={onSliderInput}
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div>
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
only the clock display remains here. */}
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button
type="button"
+26 -16
View File
@@ -32,6 +32,8 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
// ISS-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
@@ -94,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));
@@ -231,21 +235,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
// deterministic generated weather — no gameplay effects per PLAN.md.
const w = weatherFor(p.icao);
const localTime = approxLocalTime(p.lon, Date.now());
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
const st = stateRef.current;
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
const planes = st ? st.planes : [];
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
const relocateHtml =
groundedPlanes.length > 0
planes.length > 0
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
`<option value="">Select a plane\u2026</option>` +
groundedPlanes.map(
planes.map(
(pl) =>
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
).join('') +
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
const popup = new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number])
.setHTML(
@@ -263,7 +268,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const el = popup.getElement();
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
// The status span is always in the popup HTML (even with no grounded planes), so it
// The status span is always in the popup HTML (even with no planes), so it
// exists whenever the select/button do.
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
if (select && button && statusEl) {
@@ -276,7 +281,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
button.disabled = true;
statusEl.textContent = 'Relocating\u2026';
try {
const res = await fetch(`/api/planes/${planeId}/fly`, {
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
// planes fly now, flying/repairing ones get the leg queued for after landing.
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destIcao: p.icao }),
@@ -285,7 +294,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
statusEl.textContent = 'Relocation started.';
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
} catch (err) {
statusEl.textContent = err instanceof Error ? err.message : String(err);
button.disabled = false; // allow retry / picking another plane
@@ -351,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',
},
@@ -377,8 +386,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
});
}
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
// layers so a hovered trace still draws on top of it. Amber/gold + fine dots reads as
// "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson',
@@ -391,14 +400,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
type: 'line',
source: QUEUE_ROUTES_SOURCE_ID,
paint: {
'line-color': '#64748b', // muted slate — planned legs, not actively flying
'line-color': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
},
});
}
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({
@@ -452,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,
},
});
+10 -5
View File
@@ -3,8 +3,8 @@
import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
import { guaranteedRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
@@ -234,10 +234,14 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</div>
<div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
{/* v0.65.0: open jobs expire on the WALL clock — live countdown from expiresRealMs;
re-renders every poll tick (~1 s) keep it current without a timer. */}
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
{formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())}
</span>
</div>
</li>
@@ -320,7 +324,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
All values come from the snapshot's catalog; max range is the GUARANTEED full-tank range —
worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */}
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
<h4 className="plane-type-name">{e.label}</h4>
@@ -351,7 +356,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<div className="stat-row">
<span>Max range</span>
<strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
{Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
-91
View File
@@ -1,91 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane';
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
describe('pendingScaleConfirmed', () => {
it('is false while there is no pending value', () => {
expect(pendingScaleConfirmed(null, 1)).toBe(false);
expect(pendingScaleConfirmed(null, 64)).toBe(false);
});
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
expect(pendingScaleConfirmed(64, 1)).toBe(false);
expect(pendingScaleConfirmed(32, 8)).toBe(false);
});
it('becomes true exactly when a snapshot carries the pending scale', () => {
expect(pendingScaleConfirmed(64, 64)).toBe(true);
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
});
});
// ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the
// error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer
// intent supersedes the in-flight body instead of waiting out its retries.
describe('retrySimPost', () => {
it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => {
expect(SIM_POST_MAX_ATTEMPTS).toBe(3);
expect(SIM_POST_RETRY_DELAY_MS).toBe(500);
});
it('succeeds on the first attempt without sleeping or retrying', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
it('retries with the backoff delay and succeeds on a later attempt', async () => {
let calls = 0;
const post = vi.fn(async () => {
if (++calls < 3) throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts
expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS);
});
it('gives up after the final attempt with the last error (no trailing sleep)', async () => {
const err = new Error('down');
const post = vi.fn(async () => {
throw err;
});
const sleep = vi.fn(async () => {});
const result = await retrySimPost(post, () => false, sleep);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('exhausted');
if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error
}
expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS);
expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1);
});
it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => {
let calls = 0;
const post = vi.fn(async () => {
calls++; // flip "superseded" right after the first failure, like a queued commitNow would
throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead
expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep
});
it('never even sends a body that is already superseded', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).not.toHaveBeenCalled();
expect(sleep).not.toHaveBeenCalled();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
describe('queued route line color', () => {
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
});
});
+3 -1
View File
@@ -42,10 +42,12 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoCount: 4,
weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 1_000_000,
status: 'open',
planeId: null,
createdSimTime: 0,
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
...over,
};
}
@@ -53,7 +55,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
return {
account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false },
simState: { clockMs: 0 },
planes,
packages,
catalog: CATALOG,
+365 -1
View File
@@ -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","","","",""
1 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
11 610368 CA-1292 large_airport YEG 50.341652 -106.699219 2373 NA CA CA-AB Edmonton yes
12 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
13 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
14 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://ylw.kelowna.ca/ https://en.wikipedia.org/wiki/Kelowna_International_Airport Kelowna, Okanagan
15 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
16 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
17 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 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 齐齐哈尔, 齐齐哈尔三家子机场
1119 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
1120 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 沈阳, 沈阳桃仙国际机场
1121 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
1122 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
1123 9296 2AK8 small_airport Icy Cape Air Force Station Airport 70.293545 -161.907151 48 NA US US-AK Icy Cape no 2AK8 2AK8
1124 9902 2Z1 seaplane_base Entrance Island Seaplane Base 57.412201 -133.43848 0 NA US US-AK Entrance Island no HBH 2Z1
1125 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
1126 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
1127 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
1128 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
1129 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
1130 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
1131 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
1132 312361 ANZ small_airport Angus Downs Airport -25.0325 132.2748 1724 OC AU AU-NT Angus Downs Station no ANZ YADN
1133 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
1134 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
1135 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
1136 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
1137 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
1138 603000 AQ-0016 medium_airport Grandfather Skiway -77.530242 -31.827393 AN AQ AQ-U-A Antartica no
1139 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
1140 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
1141 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
1142 333856 AU-0112 small_airport Aileron Airstrip -22.652908 133.347244 2186 OC AU AU-NT Anmatjere no YALR
1143 351706 AU-0272 small_airport Planet Downs OS -25.872662 141.116788 OC AU AU-QLD Planet downs OS no
1144 352795 AU-0345 small_airport Hamilton Downs Airstrip -23.507335 133.260577 OC AU AU-NT Burt Plain no
1145 27846 YNPB small_airport Napperby Airport -22.5312 132.7632 2135 OC AU AU-NT Napperby no NPP YNPB
1146 27793 YMNY small_airport Morney Airport -25.358299255371094 141.43299865722656 104 OC AU AU-QLD no YMNY OXY YMNY
1147 507903 AU-0571 small_airport Lake MacKay Airstrip -23.670782 129.976158 OC AU AU-NT Lake MacKay no
1148 508151 AU-0586 small_airport homestead -23.317075 138.587637 OC AU AU-NT Sturt QLD 4829 no
1149 38379 YHBY small_airport Henbury Airport -24.584 133.236 OC AU AU-NT Ghan no YHBY HRY YHBY
1150 31745 AU-KGR small_airport Kulgera Airport -25.8428 133.292007 OC AU AU-NT Ghan no KGR
1151 44051 BGDH small_airport Danmarkshavn Landing Strip 76.76667 -18.666668 NA GL GL-NE Danmarkshavn no BGDH
1152 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
1153 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
1154 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
1155 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
1156 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
1157 316098 CA-0551 seaplane_base Cameron Bay Water Aerodrome 66.06 -117.89 512 NA CA CA-NT Cameron Bay no EA7
1158 316114 CA-0554 seaplane_base Clarence Lagoon Water Aerodrome 69.62 -140.81 0 NA CA CA-YT no EC7
1159 316197 CA-0556 seaplane_base Boffa Lake Water Aerodrome 69.637 -116.196 338 NA CA CA-NU no EX6
1160 342506 CA-0737 small_airport Seal River Airstrip 59.154598 -94.785554 20 NA CA CA-MB Churchill no
1161 342507 CA-0738 small_airport Nester One Airstrip 58.6529 -93.19061 26 NA CA CA-MB Churchill no
1162 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
1163 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
1164 430490 CA-0815 small_airport Nuskenford Airstrip 69.863861 -126.843167 NA CA CA-NT Nuskenford no
1165 524817 CA-0924 seaplane_base Herschel Island Water Aerodrome 69.572 -138.93 0 NA CA CA-YT Herschel no EG8
1166 595702 CA-1179 small_airport Cassiar Aerodrome 59.277137 -129.805044 3618 NA CA CA-BC Cassiar no AU9
1167 606385 CA-1249 small_airport Fort Conger Airstrip 81.754925 -64.824958 15 NA CA CA-NU Qikiqtaaluk Region no CA1249 FCON
1168 606387 CA-1250 small_airport Lake Hazen Airstrip 81.823025 -71.360321 10 NA CA CA-NU Qikiqtaaluk Region no CA1250 LHAZ
1169 606493 CA-1251 small_airport McGill Arctic Research Station (MARS) 79.41596 -90.761018 NA CA CA-NU Axel Heiberg Island no
1170 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
1171 609524 CA-1259 small_airport Bathurst Inlet Airport 66.836865 -108.011278 36 NA CA CA-NU Bathurst Inlet/Qingaut no ᕿᙵᐅᓐ
1172 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
1173 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
1174 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
1175 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
1176 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
1177 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
1178 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
1179 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
1180 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
1181 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
1182 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
1183 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
1184 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
1185 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
1186 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
1187 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
1188 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
1189 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
1190 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
1191 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
1192 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
1193 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
1194 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
1195 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
1196 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
1197 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
1198 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
1199 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
1200 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
1201 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
1202 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
1203 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
1204 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
1205 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
1206 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
1207 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
1208 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
1209 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
1210 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
1211 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
1212 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
1213 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
1214 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
1215 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
1216 300457 CWJC small_airport Ennadai Lake Airport 61.1333333333 -100.9 1021 NA CA CA-NU Ennadai no CWJC
1217 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
1218 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
1219 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
1220 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
1221 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
1222 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
1223 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
1224 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
1225 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
1226 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
1227 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
1228 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
1229 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
1230 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
1231 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
1232 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
1233 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
1234 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
1235 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
1236 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
1237 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
1238 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
1239 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
1240 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
1241 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
1242 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
1243 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
1244 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
1245 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
1246 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
1247 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
1248 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
1249 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
1250 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
1251 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
1252 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
1253 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
1254 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
1255 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
1256 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
1257 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
1258 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
1259 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
1260 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
1261 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
1262 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
1263 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
1264 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
1265 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#
1266 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
1267 44064 GL-0003 small_airport NorthGRIP Airport 75.099998 -42.316666 NA GL GL-NE NorthGRIP no GIRPA, North Greenland Ice Core Project
1268 505046 GL-0014 small_airport EastGRIP Airport 75.63 -35.99 NA GL GL-NE EastGRIP no East Greenland Ice-Core Project
1269 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
1270 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
1271 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
1272 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
1273 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
1274 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
1275 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
1276 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
1277 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
1278 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
1279 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
1280 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
1281 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
1282 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
1283 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
1284 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
1285 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
1286 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
1287 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
1288 32053 NTTX small_airport Moruroa Airport -21.80978 -138.813385 7 OC PF PF-TG Moruroa Atoll no NTTX UOA NTTX Mururoa, Mururura, Aopuni
1289 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
1290 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
1291 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
1292 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
1293 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
1294 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
1295 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
1296 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
1297 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
1298 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
1299 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
1300 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)
1301 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
1302 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
1303 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
1304 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
1305 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
1306 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
1307 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
1308 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
1309 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
1310 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
1311 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
1312 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
1313 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
1314 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)
1315 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
1316 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
1317 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
1318 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ō
1319 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 УЕСУ, Аэропорт Зырянка
1320 323626 RU-0472 small_airport Lamutskoye Airport 65.538838 168.844848 601 AS RU RU-CHU Lamutskoye no UHAL UHAL УХАЛ, Ламутское
1321 338091 RU-0543 small_airport Tempa Airfield 75.76713 137.576637 3 EU RU RU-SA Kotelny Island no Темп
1322 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, Аэропорт Анадырь
1323 340795 RU-0605 small_airport Uelkal Airstrip 65.530823 -179.287263 EU RU RU-CHU Uelkal no
1324 340810 RU-0616 small_airport Taymylyr Airstrip 72.607271 121.893179 EU RU RU-SA Taymylyr no
1325 341832 RU-0676 small_airport Ayanka Airport 63.709269 167.584196 295 AS RU RU-KAM Ayanka no
1326 346936 RU-0727 small_airport Khailino Airport 60.95206 166.83801 180 AS RU RU-KAM Khailino no UHPF УХПФ, Хаилино
1327 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
1328 346939 RU-0730 small_airport Sredny Pakhachi Airport 60.81718 169.07555 AS RU RU-KAM Sredny Pakhachi no
1329 346948 RU-0738 small_airport Garmanda Airport 62.18334 159.08894 AS RU RU-MAG Garmanda no UEVE
1330 347894 RU-0753 small_airport Zhilinda Airport 70.15387 113.91756 489 EU RU RU-SA Zhilinda no
1331 347895 RU-0754 small_airport Ebelyakh Airport 70.88319 113.58159 387 EU RU RU-SA Ebelyakh no
1332 347902 RU-0760 small_airport Yukta Airport 63.38788 105.65577 680 EU RU RU-KYA Yukta no
1333 347903 RU-0761 small_airport Nakanno Airport 62.89988 108.44788 816 EU RU RU-IRK Nakanno no
1334 347904 RU-0762 small_airport Khamakar Airport 61.989 108.17978 889 EU RU RU-IRK Khamakar no
1335 601237 RU-10132 small_airport Yagodnoye Airfield 62.54574 149.52257 1646 AS RU RU-MAG Yagodnoye no UHGE УХГЕ, Ягодное,
1336 602171 RU-10134 small_airport Valunistoe (airport) 66.421687 177.582464 AS RU RU-CHU Rudnik Valunisty no
1337 602186 RU-10135 small_airport Valunistoe Mining Complex Airport 66.766633 164.319284 999 AS RU RU-CHU Vesenny no
1338 602304 RU-10136 small_airport Verkhnee Penzhino Airport 64.222657 164.244866 1093 AS RU RU-KAM Penzhinsky District no Верхнее Пенжино
1339 609963 RU-10173 small_airport Stadukhino Airfield 66.965897 166.82135 1905 AS RU RU-CHU Stadukhino no ZC8P ЗЦ8П, Стадукхино
1340 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, Аэродром Греэм Белл, Аэродром Грээм Белл, ЬЛДГ
1341 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
1342 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
1343 39076 SAWZ small_airport Matienzo Airfield -64.976998 -60.108992 75 AN AQ AQ-U-A Matienzo Base no SAWZ BTM
1344 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
1345 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
1346 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
1347 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
1348 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
1349 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
1350 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
1351 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
1352 41131 SEII small_airport General Villamil Airport -0.942628 -90.953003 35 SA EC EC-W Puerto Villamil yes SEII IBB SEII
1353 32508 UEBN small_airport Nizhneyansk Airport 71.437822 136.189701 20 AS RU RU-SA Nizhneyansk no UEBN Нижнея́нск
1354 335165 UEKY small_airport Keng-Kyuyol Airport 68.076828 145.985126 EU RU RU-SA Keng-Kyuyol no UEKY
1355 335179 UEMC small_airport Khatyngnakh Airport 67.489845 152.575679 EU RU RU-SA no UEMC
1356 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, Аэропорт Оленёк, Аэропорт Оленек
1357 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 УЕРС, Аэропорт Саскылах
1358 335193 UESC small_airport Berezovka Airport 67.120183 156.589766 AS RU RU-SA no UESC УЕСЦ, Берёзовка
1359 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 УЕСГ, Аэропорт Белая Гора
1360 335194 UESH small_airport Argakhtakh Airport 68.443333 153.362222 65 AS RU RU-SA Argakhtakh no UESH УЕСХ, Аргахтах
1361 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 УЕСК, Среднеколымск
1362 335195 UESL small_airport Sylgy-Ytar Airport 67.848145 154.838862 164 AS RU RU-SA no UESL УЕСЛ, Сылгы-Ытар
1363 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, Аэропорт Чокурдах
1364 335196 UESR small_airport Tumat Airport 70.72611 139.235833 EU RU RU-SA no UESR
1365 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, Аэропорт Черский
1366 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 УЕСТ, Аеропорт Тикси
1367 335197 UESV small_airport Svatay Airport 68.057338 151.79625 75 AS RU RU-SA Svatay no UESW УЕСВ, Сватай
1368 335198 UESX small_airport Aleko-Kyuyol Airport 68.676693 151.873798 82 AS RU RU-SA no UESX УЕСЬ, Алеко-Кюель
1369 335200 UESZ small_airport Kazachye Airport 70.746294 136.179271 EU RU RU-SA no UESZ
1370 323629 UHAC small_airport Chuvanskoye Airport 65.172163 167.952484 644 AS RU RU-CHU Chuvanskoye no UHAC UHAC УХАЦ, Чуванское
1371 323630 UHAW small_airport Vayegi Airport 64.167442 171.057887 131 AS RU RU-CHU Vayegi no UHAW UHAW УХАВ, Ваеги
1372 324924 UHDW small_airport Vankarem Airport 67.838719 -175.8494283 3 EU RU RU-CHU Vankarem no UHDW УХДВ, Ванкарем
1373 530668 UHEI small_airport Ilirney Airport 67.255278 167.949167 1165 AS RU RU-CHU no UHEI
1374 525055 UHEK small_airport Kupol Airport 66.901667 169.56 1745 AS RU RU-CHU no UHEK https://en.wikipedia.org/wiki/Kupol_Airport
1375 596406 UHGL small_airport Glukharinoye Airfield 64.999872 153.096328 977 AS RU RU-MAG Glukharinoye no UHGL УХГЛ, Глухариное, Глухариный
1376 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, Аэропорт Бухта Провидения, Аэропорт Урелики
1377 43686 UHME small_airport Zaliv Kresta Airport 66.358398 -179.108393 22 AS RU RU-CHU Egvekinot no UHME УХМЕ, Аэропорт Залив Креста
1378 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 Аэропорт Омсукчан
1379 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 УХМИ, ЬХМИ, Мыс Шмидта
1380 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, Аэропорт Кепервеем
1381 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
1382 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 УХМН, Аэропорт Омолон
1383 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 Аэропорт Марково
1384 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 УХМП, Аэропорт Певек
1385 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 УХМР, Аэропорт Беринговский
1386 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 УХМС, Аэропорт Сеймчан
1387 324631 UHMZ small_airport Wrangel Island Airport 70.945356 -179.5 112 EU RU RU-CHU Wrangel Island no UHMZ UHMZ УХМЗ, Остров Врангеля
1388 339646 UHPN small_airport Manily Airport 62.485962 165.338917 33 AS RU RU-KAM Manily no UHPN
1389 597129 UHQE small_airport Slautnoye Airfield 63.176423 167.978281 AS RU RU-KAM Slautnoye no UHQE УХЯЕ, Слаутное
1390 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, Аэропорт Ербогачен, Аэропорт Ербогачён
1391 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, Аэропорт Варандей
1392 315363 UODL small_airport Cape Chelyuskin Airfield 77.712 104.24 60 AS RU RU-KYA Chelyuskin Polar Station no UODL UODL УОДЛ, Челюскин
1393 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
1394 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, Аэродром Средний Остров, ЬОДС
1395 301864 US-0200 small_airport Kogru River Airfield 70.574196 -152.259478 NA US US-AK Kogru no A41
1396 340805 US-3039 small_airport Brownlow Point Airport 69.979281 -144.834986 NA US US-AK Brownlow Point no
1397 340807 US-3041 small_airport Demarcation Bay Airfield 69.882551 -142.305616 NA US US-AK Nuvagapak Point no Nuvagapak Point
1398 316465 WAN small_airport Waverney Airport -25.356917 141.925593 570 OC AU AU-QLD Waverney no WAN
1399 354533 YABL small_airport Ambalindum Airport -23.35187 134.732434 1966 OC AU AU-NT Ambalindum no YABL
1400 27317 YADO small_airport Andado Airport -25.407772 135.290794 549 OC AU AU-NT Ghan no YADO
1401 38224 YALC small_airport Alcoota Station Airport -22.801963 134.4036 1994 OC AU AU-NT Alcoota no YALC Engawala
1402 38226 YALM small_airport Allambie Airport -24.265335 134.402927 OC AU AU-NT no YALM
1403 38229 YAMM small_airport Ammaroo Airport -21.738300323486328 135.24200439453125 OC AU AU-NT no AMX YAMM
1404 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
1405 38230 YANJ small_airport Angatja Airport -26.100000381469727 130.39999389648438 OC AU AU-SA no YANJ
1406 38231 YANN small_airport Anningie Airport -21.83169937133789 133.125 OC AU AU-NT no YANN
1407 27332 YANW small_airport Annitowa Airport -21.200017 136.451139 901 OC AU AU-NT no YANW
1408 27336 YARD small_airport Argadargada Airport -21.676700592041016 136.67799377441406 OC AU AU-NT no YARD
1409 27340 YARN small_airport Areyonga Airport -24.091565 132.271228 OC AU AU-NT Mereenie no YARN
1410 38232 YARP small_airport Arapunya Airport -22.316699981689453 135.6999969482422 OC AU AU-NT no YARP
1411 38233 YATL small_airport Atula Airport -23.255344 136.388955 915 OC AU AU-NT Anatye no YATL
1412 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
1413 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
1414 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
1415 27369 YBEO small_airport Betoota Airport -25.694791 140.738339 91 OC AU AU-QLD no YBEO BTX YBEO
1416 38256 YBRC small_airport Barrow Creek Airport -21.542202 133.871959 OC AU AU-NT no YBRC
1417 27416 YBSR small_airport Blackstone Airport -25.9867000579834 128.28799438476562 OC AU AU-WA no YBSR
1418 38278 YCCK small_airport Canteen Creek Airport -20.650800704956055 135.5850067138672 OC AU AU-NT no YCCK
1419 38293 YCNS small_airport Coniston Airport -22.13330078125 132.5330047607422 OC AU AU-NT no YCNS
1420 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
1421 38304 YCSP small_airport Curtin Springs Airport -25.333921 131.759308 OC AU AU-NT no YCSP
1422 38308 YCUD small_airport Cuddapan Airport -25.59000015258789 141.50799560546875 OC AU AU-QLD no YCUD
1423 38460 YMDS small_airport Macdonald Downs Airport -22.444000244099996 135.199005127 OC AU AU-NT no YMDS MNW YMDS
1424 27532 YDRI small_airport Durrie Airport -25.684999465942383 140.22799682617188 58 OC AU AU-QLD no YDRI DRR YDRI
1425 38330 YDRL small_airport De Rose Hill Airport -26.435838 133.259954 OC AU AU-SA De Rose Hill no YDRL
1426 27535 YDVR small_airport Docker River Airport -24.859533 129.122209 589 OC AU AU-NT Kaltukatjara no YDVR DKV YDVR
1427 38334 YDYD small_airport Derry Downs Airport -22.08329963684082 135.33299255371094 OC AU AU-NT no YDYD
1428 38338 YELK small_airport Elkedra Airport -21.172500610399997 135.444000244 OC AU AU-NT no EKD YELK
1429 38342 YEPR small_airport Epenarra Airport -20.433300018310547 135.26699829101562 OC AU AU-NT no YEPR
1430 38345 YERL small_airport Erldunda Airport -25.206106 133.254161 OC AU AU-NT Ghan no EDD YERL
1431 27554 YERN small_airport Ernabella Airport -26.2632999420166 132.1820068359375 707 OC AU AU-SA no YERN ERB YERN
1432 27559 YEVP small_airport Everard Park Airport -27.038988 132.688585 1615 OC AU AU-SA Mimili no YEVP
1433 31129 YFNE small_airport Finke Airport -25.5946998596 134.582992554 OC AU AU-NT Finke no YFNE FIK YFNE
1434 27566 YFRG small_airport Fregon Airport -26.774999618530273 132.01699829101562 528 OC AU AU-SA no YFRG
1435 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
1436 27631 YIDK small_airport Indulkana Airport -26.965435 133.321989 401 OC AU AU-SA Indulkana no YIDK IDK YIDK
1437 27594 YGLE small_airport Glengyle Airport -24.808300018310547 139.60000610351562 91 OC AU AU-QLD no YGLE GLG YGLE
1438 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
1439 27612 YHAA small_airport Haasts Bluff Airport -23.454999923706 131.85299682617 2269 OC AU AU-NT no YHAA
1440 27619 YHKT small_airport Huckitta Airport -22.943300247192383 135.44700622558594 OC AU AU-NT no YHKT
1441 27621 YHMB small_airport Hermannsburg Airport -23.926485 132.807798 593 OC AU AU-NT Hermannsburg (Ntaria) no YHMB HMG YHMB
1442 38390 YHSB small_airport Horseshoe Bend Airport -25.216699600219727 134.26699829101562 OC AU AU-NT no YHSB
1443 38394 YHTS small_airport Harts Range Airport -22.985000610351562 134.91799926757812 OC AU AU-NT no YHTS
1444 27632 YIDR small_airport Idracowra Airport -25.06329917907715 133.73300170898438 OC AU AU-NT no YIDR
1445 38396 YIKL small_airport Baikal Airport -22.7632999420166 136.16299438476562 OC AU AU-NT no YIKL
1446 27647 YJAM small_airport Jameson Airport -25.850000381469727 127.68299865722656 OC AU AU-WA no YJAM
1447 38407 YKCA small_airport Kings Canyon Airport -24.260229 131.488924 2100 OC AU AU-NT Petermann no YKCA KBJ YKCA
1448 38425 YLBG small_airport Mount Liebig Airport -23.24329948425293 131.25999450683594 OC AU AU-NT no YLBG
1449 27719 YLGN small_airport Loongana Airport -30.940771 127.028781 OC AU AU-WA no YLGN
1450 27727 YLLC small_airport Lilla Creek Airport -25.55791 134.07114 OC AU AU-NT no YLLC
1451 38444 YLSY small_airport Mount Lindsay Airport -27.018299102783203 129.88499450683594 OC AU AU-SA no YLSY
1452 27749 YMAD small_airport Madura Airport -31.887825 127.025573 OC AU AU-WA no YMAD
1453 32730 YMBX small_airport Mundrabilla Airport -31.86720085144043 127.85399627685547 120 OC AU AU-WA Mundrabilla no YMBX
1454 38455 YMBZ small_airport Mount Ebenezer Airport -25.169763 132.643107 OC AU AU-NT no YMBZ
1455 27763 YMDV small_airport Mount Davies Airport -26.16670036315918 129.13299560546875 OC AU AU-SA no YMDV
1456 27789 YMNK small_airport Monkira Airport -24.816699981689453 140.5330047607422 107 OC AU AU-QLD no YMNK ONR YMNK
1457 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
1458 31943 YMQA small_airport Marqua Airport -22.80579948425293 137.25100708007812 OC AU AU-NT Marqua no YMQA MQE YMQA
1459 38495 YMSK small_airport Mount Skinner Airport -22.187801 134.171648 OC AU AU-NT no YMSK
1460 27812 YMTX small_airport Mount Dare Airport -26.06170082092285 135.2469940185547 OC AU AU-SA no YMTX
1461 38500 YMUP small_airport Mulga Park Airport -25.860000610399997 131.649993896 OC AU AU-NT no YMUP MUP YMUP
1462 27823 YMWE small_airport Mount Wedge Airport -22.737199783325195 132.1510009765625 559 OC AU AU-NT no YMWE
1463 38507 YMYV small_airport Maryvale Airport -24.647992 134.026358 OC AU AU-NT no YMYV
1464 38508 YMYW small_airport Murray Downs Airport -21.04829978942871 134.68299865722656 OC AU AU-NT no YMYW
1465 38513 YNHV small_airport New Haven Airport -22.728300094604492 131.14500427246094 OC AU AU-NT no YNHV
1466 27850 YNRR small_airport Nyrripi Airport -22.645299911499023 130.56500244140625 OC AU AU-NT no YNRR
1467 38523 YNUE small_airport Numery Airport -23.983299255371094 135.39999389648438 OC AU AU-NT no YNUE
1468 38528 YNWT small_airport Narwietooma Airport -23.228786 132.629378 OC AU AU-NT no YNWT
1469 38535 YORA small_airport Moonerah Airport -31.691413 126.590523 OC AU AU-WA no YORA
1470 38537 YORT small_airport Ooratippra Airport -21.905799865722656 136.06900024414062 OC AU AU-NT no YORT
1471 354815 YPHS small_airport Pine Hill Station Airport -22.386977 133.06025 2142 OC AU AU-NT Anmatjere no YPHS
1472 27924 YRNW small_airport Ringwood Airport -23.82830047607422 134.96200561523438 OC AU AU-NT no YRNW
1473 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
1474 38607 YTJU small_airport Tjuntjuntjarra Airport -29.344938 127.107348 740 OC AU AU-WA no YTJU
1475 38609 YTLT small_airport Tarlton Downs Airport -22.63330078125 136.8000030517578 OC AU AU-NT no YTLT
1476 32491 YTMY small_airport Tobermorey Airport -22.255800247192383 137.9530029296875 OC AU AU-NT Tobermorey no YTMY TYP YTMY
1477 28014 YTYN small_airport Tieyon Airport -26.216699600219727 133.91700744628906 OC AU AU-SA no YTYN
1478 38623 YUMB small_airport Umbeara Airport -25.746700286865234 133.70199584960938 OC AU AU-NT no YUMB
1479 38625 YUTP small_airport Utopia Airport -22.05500030517578 134.7899932861328 OC AU AU-NT no YUTP
1480 38629 YVNS small_airport Vaughan Springs Airport -22.33329963684082 130.86700439453125 OC AU AU-NT no YVNS
1481 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
1482 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
1483 38645 YWLA small_airport Willowra Airport -21.27669906616211 132.6230010986328 OC AU AU-NT no YWLA
1484 38647 YWLH small_airport Wallara Ranch Airport -24.649999618530273 132.31700134277344 OC AU AU-NT no YWLH
+5
View File
@@ -14,6 +14,11 @@ services:
image: flight-ops-ci
environment:
FLIGHT_TEST_HOOKS: "1"
# v0.65.0: shorten the pool refill cooldowns to seconds so e2e can watch taken slots come back;
# the job LIFETIME stays at its 1 h default on purpose — state.spec.ts restarts the container
# mid-suite and must not race a whole board expiring (the expire-package hook ages jobs instead).
FLIGHT_JOB_REFILL_MS: "5000"
FLIGHT_JOB_SMALL_REFILL_MS: "2000"
volumes:
# Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public
# before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's
+4 -5
View File
@@ -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:
+70 -33
View File
@@ -14,7 +14,8 @@ import { windBurnFactor } from '../lib/sim/movement';
// penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
@@ -39,7 +40,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
packages: Array<{
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
@@ -49,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.
@@ -69,11 +75,6 @@ async function createPackage(
return (await res.json()) as StateBody['packages'][number];
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
@@ -82,15 +83,39 @@ 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);
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
// resume it or the clock stays frozen and the next wait hangs.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// 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);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
@@ -110,10 +135,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
}
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
// Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
test.afterEach(async ({ page }) => {
await postSim(page, { timeScale: 1, paused: false });
await setScale(page, WORLD);
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
@@ -142,8 +167,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
// 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.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
@@ -158,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');
}
@@ -197,6 +225,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
@@ -253,8 +283,7 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
// Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
await setScale(page, SCALE);
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
@@ -302,7 +331,6 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page);
const before = await getState(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
@@ -316,12 +344,23 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy();
}
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
// 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).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
} finally {
await deactivateWorld(page);
}
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
let s = before;
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
@@ -343,8 +382,7 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
// Restore: top up + fly home at SCALE.
await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
@@ -384,8 +422,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409);
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
// Fly home from the hop so the fuel section starts at the hub, at SCALE.
await setScale(page, SCALE);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy();
+1 -1
View File
@@ -23,6 +23,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
// 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',
@@ -32,7 +33,6 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
'/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
]);
+77
View File
@@ -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);
});
+92
View File
@@ -0,0 +1,92 @@
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();
}
}
});
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state. The age hook (FLIGHT_TEST_HOOKS=1) stamps a job past its
// lifetime and runs a tick, so expiry is observable in one round-trip; self-contained like the test
// above (the assertion lives in the hook response, no /api/state polling).
test('an open job aged past its wall-clock lifetime expires on the next tick', async ({ page }) => {
const pkg = await createPriorityPackage(page, FROM, TO);
const res = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(res.status()).toBe(200);
const aged = (await res.json()) as Pkg & { status: string };
expect(aged.id).toBe(pkg.id);
expect(aged.status, 'the hook runs a tick after aging the job, so it is expired by response time').toBe('expired');
// Unknown ids and non-open jobs still 404.
const missing = await page.request.post('/api/test/expire-package', { data: { packageId: 'no-such-id' } });
expect(missing.status()).toBe(404);
const alreadyExpired = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(alreadyExpired.status()).toBe(404); // expired is not open — the hook only ages live jobs
});
+110 -46
View File
@@ -11,7 +11,8 @@ import { test, expect, type Page } from '@playwright/test';
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
const DEST = 'LEMD';
@@ -41,35 +42,34 @@ interface PackageState {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
}
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);
}
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -89,34 +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) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
// 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);
}
}
@@ -177,6 +173,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
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');
@@ -223,8 +229,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
// (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
const near = (a: [number, number], b: [number, number]) => {
const dLon = Math.abs(a[0] - b[0]);
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
};
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
@@ -350,6 +361,58 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0);
});
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a
// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to
// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at
// the hub; same crossing, no relocation needed.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, HUB, 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
// EDDF→NFTF spans ~176° of longitude — it can't fit at MIN_ZOOM=3 (components/Map.tsx), so the camera
// clamps to z=3 and both endpoints are off-viewport → queryRenderedFeatures never sees the trace.
// Lift the floor for this test's camera; quantization stays ≤~0.02° even near z=0, below EPS (:214–228).
await page.evaluate(() => (window as any).__flightMap?.setMinZoom(0));
await fitToAirports(page, [HUB, 'NFTF']);
await row.hover();
await expectHoverRoute(page, HUB, 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false; // none yet — keep polling
for (const s of segs) {
const c = s.geometry.coordinates;
for (let i = 1; i < c.length; i++) {
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
}
}
return true;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
@@ -472,8 +535,9 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused.
await postSim(page, { paused: true });
// e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
// the route line stays drawn while frozen.
await setScale(page, 1);
await block.hover();
await expect.poll(async () => {
@@ -489,7 +553,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs.
await postSim(page, { timeScale: SCALE, paused: false });
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -497,7 +561,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
@@ -535,10 +599,10 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
// airport regardless of progress, so no mid-flight position window is needed at all.
await postSim(page, { paused: true });
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. At
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
await setScale(page, 1);
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
@@ -584,7 +648,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
// not DEST — it never delivered), then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // also clears the pause above
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -595,7 +659,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
+18 -13
View File
@@ -10,7 +10,8 @@ import { REPAIR_COST } from '../lib/game/catalog';
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
// passes.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
@@ -35,24 +36,29 @@ interface CatalogEntry {
interface StateBody {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
catalog: CatalogEntry[];
}
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);
}
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
@@ -125,7 +131,7 @@ async function healStarterHome(page: Page): Promise<void> {
starter = s.planes.find((p) => p.id === STARTER)!;
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
// Make sure the world rate is back at SCALE for the return leg.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
@@ -152,11 +158,10 @@ async function restoreStarter(page: Page): Promise<void> {
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
// On exit (success or failure): restore the fixed world rate for the other specs (house convention)
// and leave a clean starter — full health, no active repair gate.
test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
await restoreStarter(page);
});
+279 -29
View File
@@ -14,7 +14,8 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
// the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD';
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top)
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface PlaneState {
id: string;
@@ -40,7 +41,7 @@ interface PackageState {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
}
@@ -59,29 +60,28 @@ 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);
}
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -120,7 +120,8 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
return pkg;
}
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over
// the area of interest first ([] until then). Same trap as run-176: Europe view + Pacific plane = [].
function sourceFeatures(page: Page, sourceId: string) {
return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap;
@@ -235,7 +236,7 @@ test('plane markers move while flying and the plane popup shows status + route',
// 2) Fly the plane and speed up — its current leg must appear as a route line.
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
await postSim(page, { timeScale: SCALE });
await setScale(page, SCALE);
await expect
.poll(async () => {
@@ -258,23 +259,30 @@ test('plane markers move while flying and the plane popup shows status + route',
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift
// on this leg. Pausing makes the position static; we resume right after the popup checks.
await postSim(page, { paused: true });
// Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
// our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
// we restore SCALE right after the popup checks.
await setScale(page, 1);
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
// would show (~km per poll pair).
let prevPos: { lat: number; lon: number } | null = null;
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false;
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
prevPos = { lat: p.lat, lon: p.lon };
return false;
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' })
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused
// above the server's analytic position is static — but the map renders from its own ~1 s
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
// above the server's analytic position barely moves — but the map renders from its own ~1 s
// poll, so right after the freeze confirmation the last rendered frame can still be up to
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
@@ -314,8 +322,8 @@ test('plane markers move while flying and the plane popup shows status + route',
// The popup's route line is the plane's own leg: origin name → destination name.
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Resume before step 5 — the landing wait needs a running clock.
await postSim(page, { timeScale: SCALE, paused: false });
// Restore SCALE before step 5 — the landing wait needs a fast clock.
await setScale(page, SCALE);
// 5) Let the flight finish, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
@@ -330,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);
}
}
}
});
+128 -75
View File
@@ -1,35 +1,95 @@
import { test, expect, type Page } from '@playwright/test';
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>;
}
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);
}
}
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// 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);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
// and repairPlane 409s on non-available planes, which would break activateWorld.
async function flyHomeViaApi(page: Page): Promise<void> {
// 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: 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 plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
}
if (plane.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/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
}
}
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
// pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
@@ -79,71 +139,51 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Refuel heading or row anymore.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
});
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
await page.goto('/');
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
// Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
// 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
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for full scale.
await activateWorld(page);
try {
await page.goto('/');
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
// Fastest scale so game seconds move visibly inside the sampling window.
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
expect(post.status()).toBe(200);
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
const sample = async () => {
const text = ((await clock.textContent()) ?? '').trim();
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
const sample = async () => {
const text = ((await clock.textContent()) ?? '').trim();
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
const t0 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t1 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
const t0 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t1 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
expect(t1).toBeGreaterThan(t0);
expect(t2).toBeGreaterThan(t1);
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
// still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
expect(total).toBeGreaterThanOrEqual(1_000_000);
expect(total).toBeLessThanOrEqual(6_000_000);
});
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
const slider = page.getByTestId('time-scale-slider');
await expect(slider).toBeVisible({ timeout: 15_000 });
// Commit to max via keyboard (input + change → immediate commitNow POST)…
await slider.focus();
await page.keyboard.press('End');
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
const seen: string[] = [];
for (let i = 0; i < 30; i++) {
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
await new Promise((r) => setTimeout(r, 25));
expect(t1).toBeGreaterThan(t0);
expect(t2).toBeGreaterThan(t1);
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
// still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
expect(total).toBeGreaterThanOrEqual(50_000);
expect(total).toBeLessThanOrEqual(200_000);
} finally {
await deactivateWorld(page);
}
expect(
seen.every((v) => v === '1000×'),
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
).toBe(true);
// …and the server confirms it.
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
// Restore to min for later tests (afterEach restores scale anyway).
await page.keyboard.press('Home');
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
});
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
@@ -157,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');
});
+180 -22
View File
@@ -1,4 +1,11 @@
import { test, expect, type Page } from '@playwright/test';
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
import { loadAirportsGeoJson } from '../lib/airports';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
@@ -7,14 +14,17 @@ import { test, expect, type Page } from '@playwright/test';
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
@@ -31,34 +41,33 @@ interface PackageState {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
}
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);
}
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -78,6 +87,17 @@ 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);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
async function setCash(page: Page, amount: number): Promise<void> {
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
// leftover cargo so every test starts from the canonical state even after an interrupted run.
async function flyHomeViaApi(page: Page): Promise<void> {
@@ -99,7 +119,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
// 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.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -239,7 +259,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === DEST;
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
await setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
@@ -266,7 +286,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
@@ -296,8 +316,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
await postSim(page, { paused: true });
// Freeze mid-flight (scale 1) so the ~10 s leg can't land under us while we assert.
await setScale(page, 1);
await page.goto('/');
await waitForAirportCount(page);
@@ -347,7 +367,7 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
@@ -450,3 +470,141 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
});
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
for (const icao of [DEST, QUEUE, HUB]) {
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
}
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
expect(p0.status).toBe('flying');
expect(p0.destIcao).toBe(DEST);
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
// Landing at DEST auto-chains EHAM (leg 2)…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
// …and landing at EHAM chains the return to HUB (leg 3).
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
});
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two legs at scale 1000
await flyHomeViaApi(page);
const s0 = await getState(page);
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
await setFuelFull(page, 'plane-starter');
const STAGED_CASH = 20_000;
await setCash(page, STAGED_CASH);
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
await setFuel(page, 'plane-starter', 0);
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
const fc = await loadAirportsGeoJson();
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
const wHub = weatherFor(HUB);
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
expect(landedFuel).toBeCloseTo(0, 6);
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
const liters = Math.max(0, target - landedFuel);
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
const s = await getState(page);
// 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…
// 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.
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
});
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one leg at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
expect(p.destIcao).toBeNull(); // no chained leg started…
expect(p.queue).toEqual([]); // …and the queue is empty
});
+100 -22
View File
@@ -5,23 +5,30 @@ import { expect, test } from '@playwright/test';
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
// the existing e2e/map.spec.ts click tests, not here.
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
}
async function getState(page: Page): Promise<StateBody> {
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
@@ -32,20 +39,13 @@ 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);
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
// resume it or the clock stays frozen and the next wait hangs.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
@@ -82,8 +82,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
// 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.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
@@ -92,10 +91,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
}
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
// fast-forward + reposition home here; then restore the fixed world rate for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
await setScale(page, WORLD);
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
@@ -195,3 +194,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
});
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
// shortest leg takes minutes of real time) — "still flying its original destination" below is
// deterministic regardless of how fast the UI moves.
const nearest = await nearestToHub(page);
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
await setScale(page, 1);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
await page.goto('/');
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}, { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () =>
page.evaluate(() => {
const m = (window as any).__flightMap;
if (!m || !m.getSource('planes')) return false;
try {
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
} catch {
return false; // source not ready yet — keep polling
}
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, POPUP_AIRPORT);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText(POPUP_AIRPORT.icao);
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
// is not grounded; while flying its option shows the DEPARTURE airport.
const planeSel = page.getByTestId('airport-popup-plane');
await expect(planeSel).toBeVisible();
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
const goBtn = page.getByTestId('airport-popup-relocate');
await expect(goBtn).toBeDisabled(); // placeholder selected
await planeSel.selectOption('plane-starter');
await expect(goBtn).toBeEnabled();
await goBtn.click();
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
// the flight queue while the plane keeps flying its original destination.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
});
+50 -43
View File
@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test';
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
@@ -18,7 +18,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
interface PlaneState {
@@ -34,6 +35,8 @@ interface PlaneState {
interface PackageState {
id: string;
originIcao: string; // v0.65.0 — pool visibility assertions need the origin
destIcao: string;
originName: string;
destName: string;
reward: number;
@@ -57,7 +60,7 @@ interface CatalogEntry {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
@@ -69,29 +72,28 @@ 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);
}
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -137,12 +139,6 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
@@ -174,9 +170,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -210,6 +205,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page);
@@ -220,6 +217,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled();
@@ -251,15 +250,19 @@ test('a priority package shows its special badge on the open-package row', async
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test('open jobs only appear where a plane can take them — foreign-origin jobs are hidden', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF}
const pkg = await createPackage(page, 'LEMD', HUB);
expect(pkg.originIcao).toBe('LEMD');
// v0.65.0: open jobs are only visible from airports where a plane can take them (the pool). LEMD is
// not in the pool while the starter sits at EDDF, so the job exists server-side but never reaches
// /api/state — no row and no disabled Load button to speak of.
expect((await getState(page)).packages.some((p) => p.id === pkg.id), 'a non-pool-origin open job must not appear in /api/state').toBe(false);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
});
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
@@ -275,13 +278,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).
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
// making the preview deterministic instead of racing the 1 s poll loop.
await postSim(page, { paused: true });
// 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 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
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}`);
@@ -299,8 +308,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
// Resume and fast-forward to arrival (poll the authoritative state).
await postSim(page, { paused: false });
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
await setScale(page, SCALE);
await expect
.poll(async () => {
@@ -315,8 +323,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run.
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200);
@@ -479,11 +486,11 @@ test('Planes market tab lists all plane types with tech specs', async ({ page })
await page.getByTestId('tab-planes-market').click();
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
// ATR card: name + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!;
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
+160 -102
View File
@@ -1,157 +1,215 @@
import { test, expect, type Page } from '@playwright/test';
// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it
// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one
// pool, at EDDF.
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
// hook 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;
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
const STARTER = 'plane-starter';
interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
planes: Array<Record<string, unknown>>;
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
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);
}
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// 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);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
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 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('a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
.toBe('idle');
const before = await getState(page);
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const clockBefore = before.simState.clockMs;
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to
// bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring
// starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board
// comes back a few seconds after the drain, not instantly.
for (let pass = 0; pass < 3; pass++) {
const s = await getState(page);
const open = s.packages.filter((p) => p.status === 'open');
if (open.length === 0) break; // drained — the pre-fix soft-lock state
for (const p of open) {
expect((await page.request.post(`/api/packages/${p.id}/ignore`)).status()).toBe(200);
}
}
// The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real
// time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)…
let topped: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.some((p) => p.status === 'open')) topped = body;
return !!topped;
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
.toBe(true);
const after = topped!;
expect(after.packages.filter((p) => p.status === 'open').length).toBeGreaterThan(0); // starvation fixed…
expect(after.planes.every((p) => p.status === 'available'), 'no plane may have taken off during the drain').toBe(true); // …with no plane flying…
expect(after.simState.clockMs, 'the grounded clock must stay exactly frozen while the market refills').toBe(clockBefore); // …and the world still frozen
});
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE });
expect(post.status()).toBe(200);
const s1 = (await post.json()) as SimState;
expect(s1.timeScale).toBe(SCALE);
expect(s1.paused).toBe(false);
await activateWorld(page);
try {
await setScale(page, SCALE);
let prev = s1.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
const body = await getState(page);
const now = body.simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
const now = (await getState(page)).simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
} finally {
await deactivateWorld(page);
}
});
test('paused freezes the clock exactly across polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE, paused: true });
expect(post.status()).toBe(200);
const s1 = (await post.json()) as SimState;
expect(s1.paused).toBe(true);
for (let i = 0; i < 3; i++) {
await sleep(500);
const body = await getState(page);
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused
}
});
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000);
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
// scale or clock activation needed.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
const moved = (await hook.json()) as { id: string; status: string };
expect(moved.id).toBe(victim!.id);
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
await postSim(page, { timeScale: SCALE });
let expiredBody: StateBody | undefined;
// Expired jobs leave the market…
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
.toBe(true);
const after = expiredBody!;
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
// …and the spawner refills the pool back up — never below its target.
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
});
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000);
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
// The hook ages the job before its final tick, so it never even appears open in /api/state.
expect(c.status).toBe('expired');
await postSim(page, { timeScale: SCALE });
let expiredBody: StateBody | undefined;
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
.toBe(true);
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
});
test('POST /api/sim validates and clamps its input', async ({ page }) => {
const clampHigh = await postSim(page, { timeScale: 2000 });
expect(clampHigh.status()).toBe(200);
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0)
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
expect(ok.status()).toBe(200);
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
const inRange = await postSim(page, { timeScale: 500 });
expect(inRange.status()).toBe(200);
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
const clampLow = await postSim(page, { timeScale: 0.1 });
expect(clampLow.status()).toBe(200);
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await postSim(page, body);
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
const res = await page.request.post('/api/test/set-timescale', { data: body });
expect(res.status()).toBe(400);
}
const notJson = await page.request.post('/api/sim', { data: 'nope{' });
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
expect(notJson.status()).toBe(400);
// Empty body is a valid no-op (just ticks + returns current state).
const empty = await postSim(page, {});
expect(empty.status()).toBe(200);
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
expect(empty.status()).toBe(400);
});
-141
View File
@@ -1,141 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
simState: SimState;
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
}
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;
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.status()).toBe(200);
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as SimState;
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
async function nearestToHub(page: Page): Promise<{ icao: string }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao };
}
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API so every test starts from the canonical state even
// after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
// flight is needed.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
}
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
const nearest = await nearestToHub(page);
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
await setScale(page, SCALE);
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200);
// Wait for the landing…
let landedBody: StateBody | undefined;
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
return !!landedBody;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
.toBe(true);
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
expect(landedBody!.simState.paused).toBe(false);
expect(landedBody!.simState.timeScale).toBe(1);
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
// resumes it.
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(fly2.status()).toBe(200);
const resumedBody = (await fly2.json()) as StateBody;
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
// Let the return leg finish so cleanup starts from a grounded plane.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
});
+64 -14
View File
@@ -10,7 +10,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>;
}
@@ -24,9 +24,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(typeof body.account.cash).toBe('number');
expect(typeof body.account.reputation).toBe('number');
expect(typeof body.simState.clockMs).toBe('number');
expect(typeof body.simState.timeScale).toBe('number');
expect(body.simState.paused).toBe(false);
expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
expect(Array.isArray(body.planes)).toBe(true);
expect(body.planes.length).toBeGreaterThanOrEqual(1);
@@ -44,6 +42,17 @@ 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 — 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');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
.toBe('idle');
const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody;
@@ -67,18 +76,59 @@ 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.
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the
// post-restart clock may be ahead — but never by more than that bound.
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
);
// 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());
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
});
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
test.setTimeout(180_000);
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
.toBe('available');
// Stage a two-leg queue on the starter…
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
expect(q1.status()).toBe(200);
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
expect(q2.status()).toBe(200);
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
// Cleanup: cancel both legs so the starter is left with an empty queue.
for (let i = 0; i < 2; i++) {
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c.status()).toBe(200);
}
});
+8 -3
View File
@@ -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 }) => {
+41 -50
View File
@@ -10,14 +10,15 @@ import { windBurnFactor } from '../lib/sim/movement';
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
// spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
// per-plane queues (scoped testids) and sell.
//
// Each test buys its own second plane via the UI (after a cash grant from the new
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
@@ -54,7 +55,7 @@ interface CatalogEntry {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
@@ -66,23 +67,22 @@ 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);
}
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
// proceeds at full speed.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -230,17 +230,14 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
await clearQueueViaApi(page, planeId);
let p = (await getState(page)).planes.find((x) => x.id === planeId);
if (!p) return; // already sold — nothing to heal
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
await setScale(page, SCALE);
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => {
const s = await getState(page);
const plane = s.planes.find((x) => x.id === planeId);
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
return plane?.status ?? '';
return s.planes.find((x) => x.id === planeId)?.status ?? '';
}, {
timeout: 240_000,
message: `waiting for an in-flight ${planeId} to land`,
@@ -249,9 +246,8 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
}
if (p.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
@@ -295,8 +291,7 @@ test.beforeEach(async ({ page }) => {
test.afterEach(async ({ page }) => {
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
@@ -348,7 +343,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page);
@@ -358,7 +353,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
// the same tick (a shared-tick arrival would race the first-landing assert).
const [nearest] = await twoNearestToHub(page);
const farthest = await farthestFlyableToHub(page);
@@ -388,8 +383,6 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
})
.toBe(true);
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
@@ -397,10 +390,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
})
.toBe(true);
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState(page);
expect(landed.simState.paused).toBe(false);
expect(landed.simState.timeScale).toBe(1);
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
@@ -408,17 +398,9 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
await setFuelFull(page, secondId);
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
expect(flyB2.status()).toBe(200);
let body = (await flyB2.json()) as StateBody;
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
// …but dispatching the LAST grounded plane restores the last manually selected speed.
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA2.status()).toBe(200);
body = (await flyA2.json()) as StateBody;
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
});
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
@@ -495,21 +477,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination…
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
// response snapshot must already show only [b] left (previously the leg stayed queued and the
// landing chained a zero-distance self-hop a→a before reaching b).
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200);
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale(page, SCALE);
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true);
// …then restore speed so the chained leg fast-forwards too.
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
// is either the outbound leg (HUB→a) or the chained leg (a→b).
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
// …fast-forward the chained leg to its landing.
await setScale(page, SCALE);
await expect
.poll(async () => {
+9
View File
@@ -0,0 +1,9 @@
import { startBackgroundTick } from '@/lib/sim/engine';
// ISS-117 (v0.52.0): the world clock advances on a ~1 s background tick even with nobody polling —
// started once at server boot (Next ≥15 instrumentation hook; runs under `next dev` and `next start`).
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
startBackgroundTick();
}
}
+61 -1
View File
@@ -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
View File
@@ -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;
+5 -2
View File
@@ -3,6 +3,8 @@ import os from 'node:os';
import path from 'node:path';
import initSqlJs from 'sql.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CLOCK_MS } from '@/lib/game/seed';
import { openGameDb, persist } from './client';
import * as schema from './schema';
@@ -111,8 +113,9 @@ describe('openGameDb', () => {
const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0);
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim?.paused).toBe(false);
expect(sim?.timeScale).toBe(1);
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
expect(sim?.timeScale).toBe(WORLD_TIME_SCALE);
// The seed was durably written: the file exists and is non-empty.
const st = await fs.stat(file);
+19 -5
View File
@@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS packages (
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
created_sim_time INTEGER NOT NULL
created_sim_time INTEGER NOT NULL,
created_real_ms INTEGER NOT NULL DEFAULT 0,
slot_free_at_real_ms INTEGER
);
CREATE TABLE IF NOT EXISTS account (
cash REAL NOT NULL,
@@ -98,6 +100,15 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('repair_until_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
}
// v0.65.0: real-time creation stamp — legacy rows backfill to NOW so they get a fresh full lifetime
// instead of expiring on the first tick (created_real_ms = 0 is far in the past).
if (!cols('packages').has('created_real_ms')) {
raw.exec(`ALTER TABLE packages ADD COLUMN created_real_ms INTEGER NOT NULL DEFAULT ${Date.now()}`);
}
// v0.65.0: per-(origin, band) refill cooldown — nullable; legacy rows stay NULL = no cooldown.
if (!cols('packages').has('slot_free_at_real_ms')) {
raw.exec('ALTER TABLE packages ADD COLUMN slot_free_at_real_ms INTEGER');
}
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
if (!cols('sim_state').has('last_selected_time_scale')) {
@@ -165,6 +176,7 @@ async function open(file: string): Promise<GameDb> {
// First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance);
}
@@ -210,12 +222,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> {
+8
View File
@@ -57,6 +57,14 @@ export const packages = sqliteTable('packages', {
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
createdRealMs: integer('created_real_ms').notNull().default(0),
// Real-time epoch ms when this (origin, band) slot was last taken — loaded or ignored (v0.65.0). The pool
// spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open
// jobs set nothing: an untaken job's slot respawns immediately.
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
});
export const account = sqliteTable('account', {
+7 -5
View File
@@ -36,9 +36,11 @@ const basePackage = (id: string): PackageState => ({
reward: 1234.5,
cargoType: 'mail',
cargoCount: 2,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
expiresRealMs: null, // v0.65.0 — wall-clock expiry; not exercised here
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0,
status: 'open',
planeId: null,
createdSimTime: 0,
@@ -46,7 +48,7 @@ const basePackage = (id: string): PackageState => ({
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0, timeScale: 1, paused: false },
simState: { clockMs: 0 },
planes: [basePlane('plane-starter')],
packages: [],
catalog: CATALOG,
@@ -58,7 +60,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
describe('diffSnapshots', () => {
it('returns [] for identical snapshots (only clock/cash drift)', () => {
const prev = snapshot();
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } });
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000 } });
expect(diffSnapshots(prev, next)).toEqual([]);
});
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
@@ -30,6 +30,14 @@ describe('formatCargo', () => {
});
});
describe('formatKm', () => {
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
expect(formatKm(554)).toBe('554km');
expect(formatKm(214.6)).toBe('215km');
expect(formatKm(0)).toBe('0km');
});
});
describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
+5
View File
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
export function formatKm(km: number): string {
return `${Math.round(km)}km`;
}
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1);
+49 -36
View File
@@ -21,7 +21,7 @@ import {
repRate,
SELL_FRACTION,
} from './catalog';
import { HUB_ICAO } from './seed';
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
import {
abandonPackage,
buyPlane,
@@ -173,7 +173,7 @@ describe('flyPlane', () => {
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), no tick in direct calls
const fc = await loadAirportsGeoJson();
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
expect(plane.lat).toBe(origin.lat);
@@ -182,34 +182,6 @@ describe('flyPlane', () => {
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
});
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
const inst = await freshDb('fly-resume.db');
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
});
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
const inst = await freshDb('fly-stayslow.db');
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
// A second grounded plane — the starter taking off leaves it on the ground.
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await flyPlane(inst, 'plane-starter', 'LEMD');
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('fly-flying.db');
await inst.db.update(schema.planes)
@@ -331,6 +303,34 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
});
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
});
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('EHAM');
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
});
});
describe('queueLeg (v0.25.0, ISS-043)', () => {
@@ -434,14 +434,14 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
});
describe('abandonPackage', () => {
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
// A loaded package created at the seeded game clock with a 10h window (ISS-117).
const REWARD = 1_000;
const WINDOW_MS = 10 * 3_600_000;
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
const inst = await freshDb(name);
// Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
await loadPackage(inst, 'p-ab', 'plane-starter');
// …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') {
@@ -466,7 +466,7 @@ describe('abandonPackage', () => {
it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
const { penalty } = await abandonPackage(inst, 'p-ab');
@@ -475,8 +475,8 @@ describe('abandonPackage', () => {
it('caps at exactly 60% at and past the deadline', async () => {
for (const [name, clock] of [
['ab-deadline.db', T0 + WINDOW_MS],
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
] as const) {
const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock });
@@ -671,6 +671,19 @@ describe('sellPlane', () => {
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
});
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await queueLeg(inst, 'plane-extra', 'LEMD');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
});
it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
@@ -694,7 +707,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
});
+18 -12
View File
@@ -90,8 +90,10 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
}
// v0.65.0: taking the job starts its slot's refill cooldown — the pool spawner holds this
// (origin, band) empty for the delay after now so the board visibly refreshes.
await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId })
.set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() })
.where(eq(schema.packages.id, packageId));
await persist(inst);
}
@@ -153,6 +155,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
const queue = parseIcaoQueue(plane.queue);
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
await inst.db.update(schema.planes)
.set({
status: 'flying',
@@ -161,19 +169,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
lat: origin.lat,
lon: origin.lon,
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
queue: JSON.stringify(queue),
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
// slow to 1× instead of pausing), and restores the last manually selected speed when this
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
// + dispatch are atomic under the sim lock.
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
await inst.db.update(schema.simState).set(sets);
await persist(inst);
}
@@ -265,8 +266,8 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with
// a new random package on the next tick. 404 unknown id; 409 unless open.
// changes. The pool spawner refills this (origin, band) slot after its refill delay —
// ignoring starts that cooldown via slotFreeAtRealMs (v0.65.0). 404 unknown id; 409 unless open.
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
@@ -275,7 +276,7 @@ export async function ignorePackage(inst: GameDb, packageId: string): Promise<vo
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
}
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: Date.now() }).where(eq(schema.packages.id, packageId));
await persist(inst);
}
@@ -340,6 +341,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
}
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
if (parseIcaoQueue(plane.queue).length > 0) {
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
+23
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { STARTING_REPUTATION } from './seed';
import { guaranteedRangeKm, MAX_WIND_BURN_FACTOR, maxRangeKm, requiredFuelFor } from '@/lib/sim/movement';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
@@ -43,3 +44,25 @@ describe('repRate', () => {
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
});
});
// v0.51.0 (ISS-099) — max range consistency: for EVERY catalog type the displayed guaranteed range
// must be flyable on a full tank under worst-case headwind burn (the same requiredFuelFor ≤ maxFuel
// rule as flyPlane's fuel guard), and tight — not padded. The closed form inverts exactly, so the
// "≤" side allows only float noise (~1e-11 L → relative 1e-9 tolerance).
describe('max range consistency (v0.51.0, ISS-099)', () => {
for (const t of Object.values(PLANE_TYPES)) {
it(`${t.id}: guaranteed range is flyable in worst-case headwind and not padded`, () => {
const r = guaranteedRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, r))
.toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
// Tight: 5% beyond the displayed range no longer fits a full tank.
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, 1.05 * r))
.toBeGreaterThan(t.maxFuel);
});
it(`${t.id}: the no-wind closed form inverts requiredFuelFor exactly`, () => {
const r = maxRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(requiredFuelFor(t.burnPerKm, t.emptyWeightKg, t.maxFuel, r)).toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
});
}
});
+5
View File
@@ -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';
+133 -32
View File
@@ -1,23 +1,29 @@
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,
PACKAGE_COUNT_MIN,
generatePackage,
generatePoolPackage,
HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
POOL_BANDS,
poolBandOf,
POOL_TARGET_PER_AIRPORT,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
rewardFor,
STARTING_CASH,
STARTING_CLOCK_MS,
STARTING_REPUTATION,
type Rng,
type SeedAirport,
@@ -61,24 +67,30 @@ describe('buildSeed', () => {
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open packages between distinct fixture airports', () => {
it('seeds a full pool of POOL_TARGET_PER_AIRPORT open packages at the hub (v0.65.0)', () => {
const seed = seedWith(42);
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
const byBand = [0, 0, 0, 0];
for (const c of seed.packages) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(c.originIcao).toBe(HUB_ICAO); // the starter's home hub — its only pool airport
const band = POOL_BANDS[poolBandOf(c.weightKg)];
byBand[poolBandOf(c.weightKg)]++;
expect(c.weightKg).toBeGreaterThanOrEqual(band.minKg); // weight stays in its band…
expect(c.weightKg).toBeLessThanOrEqual(band.maxKg); // …on both ends
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
expect(c.destIcao).not.toBe(HUB_ICAO);
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
expect(c.createdRealMs).toBe(NOW); // …but a fresh real-time lifetime from the seed moment
}
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
@@ -89,34 +101,36 @@ describe('buildSeed', () => {
}
});
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
it('gives every package a positive integer weight, and the board always offers jobs beyond the starter', () => {
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0);
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
}
// v0.65.0: the heavy bands (18–80 t) deliberately exceed the starter's payload — bigger jobs are
// what you buy better planes for, so the board must always show work it can't yet do alone.
expect(seed.packages.some((c) => c.weightKg > maxPayload)).toBe(true);
}
});
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
it('sets deadlines inside the 6–72h game-time window after the game-clock origin (scaled by any special status)', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
// ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 72 * 3_600_000 * scale);
}
});
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
// ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
// the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -134,6 +148,9 @@ describe('buildSeed', () => {
it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
expect(rewardFor(1000, 500)).toBe(4_000);
expect(rewardFor(1000, 4_000)).toBe(11_000);
});
});
@@ -163,8 +180,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
expect(a).toEqual(b);
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
@@ -178,12 +195,15 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
it('buildSeed packages are exactly the shared pool builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = [];
for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
let n = 1;
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePoolPackage({ airports: AIRPORTS, originIcao: HUB_ICAO, bandIdx, rng: rngA, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: NOW })));
}
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
@@ -225,8 +245,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
expect(a).toEqual(b);
});
@@ -239,8 +259,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
// Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW });
expect(a).toEqual(b);
});
});
@@ -257,7 +277,7 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234, createdRealMs: c.createdRealMs });
expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
@@ -314,8 +334,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('defaults to the starting reputation when omitted — byte-identical output', () => {
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION, createdRealMs: NOW });
expect(a).toEqual(b); // no bonus at the starting value
});
@@ -430,3 +450,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
});
});
+127 -20
View File
@@ -1,5 +1,7 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { travelDurationMs } from '@/lib/sim/movement';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
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)
@@ -61,6 +63,9 @@ export interface SeedPackage {
status: 'open';
planeId: null;
createdSimTime: number;
// Real-time (Date.now()) creation stamp (v0.65.0) — open jobs expire on the wall clock, not the sim
// clock, so this is what their lifetime is measured from.
createdRealMs: number;
}
export interface SeedData {
@@ -75,24 +80,63 @@ export interface SeedData {
export const STARTING_CASH = 20_000;
export const STARTING_REPUTATION = 10;
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
// The starter plane grounds at a large central-European hub. If the ICAO is
// ever missing from the dataset we fall back to the first airport rather than
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const PACKAGE_COUNT_MIN = 8;
export const PACKAGE_COUNT_MAX = 12; // inclusive
// v0.65.0: per-airport job pools replace the global market target (v0.7.0). Each airport where a
// plane can take work keeps this fixed composition of open packages — heavier bands are rarer, so
// big jobs feel like opportunities instead of background noise.
export const POOL_BANDS = [
{ minKg: 500, maxKg: 3_999, count: 2 }, // <4 t
{ minKg: 4_000, maxKg: 17_999, count: 6 }, // 4–18 t
{ minKg: 18_000, maxKg: 48_000, count: 1 }, // 18–48 t
{ minKg: 48_001, maxKg: 80_000, count: 1 }, // >48 t
] as const;
// Market target (v0.7.0): the sim engine tops open packages back up to this
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Which pool band a package's weight falls into (v0.65.0) — the spawner counts open packages per
// band and holds taken slots per band, so this must agree with POOL_BANDS' boundaries exactly.
export function poolBandOf(weightKg: number): number {
if (weightKg < 4_000) return 0;
if (weightKg < 18_000) return 1;
if (weightKg <= 48_000) return 2;
return 3;
}
// Total open packages per pool airport — the seed builds exactly this at the starter's home hub.
export const POOL_TARGET_PER_AIRPORT = POOL_BANDS.reduce((sum, b) => sum + b.count, 0);
// v0.65.0: open jobs live on the WALL clock, not the sim clock — they expire JOB_LIFETIME_MS after
// creation regardless of time scale or fleet state (checked every tick), so a paused world still
// refreshes its board. Test hooks may shorten these via env when FLIGHT_TEST_HOOKS=1 (CI sets the
// refill delays to seconds; the lifetime stays at 1 h everywhere).
function envMs(name: string, fallback: number): number {
if (process.env.FLIGHT_TEST_HOOKS !== '1') return fallback;
const v = Number(process.env[name]);
return Number.isFinite(v) && v > 0 ? Math.round(v) : fallback;
}
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
// Refill cooldown after a job is TAKEN (loaded or ignored): the slot stays empty this long so the
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
export const SMALL_REFILL_DELAY_MS = envMs('FLIGHT_JOB_SMALL_REFILL_MS', 60_000); // 1 min wall clock
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 0.5;
export const REWARD_PER_KG = 2.0;
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value.
@@ -115,6 +159,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
@@ -149,6 +197,11 @@ 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.
// v0.65.0: createdRealMs optionally FORCES the wall-clock creation stamp (default Date.now()) — a pure
// input consuming no RNG draws; the seed passes it explicitly so seeded worlds stay deterministic.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -160,6 +213,7 @@ export function generatePackage(input: {
weightKg?: number;
special?: string | null;
reputation?: number;
createdRealMs?: number;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -215,6 +269,9 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
// v0.65.0: a FORCED weight overrides the drawn count so display/payload math agree with weightKg —
// pool packages carry band weights far beyond the starter's payload, where the drawn count would lie.
const finalCargoCount = input.weightKg !== undefined ? Math.max(1, Math.round(input.weightKg / UNIT_WEIGHT_KG[cargoType])) : cargoCount;
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
// below AFTER the appended special draw so the fixed order above holds.
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
@@ -222,8 +279,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);
@@ -234,16 +298,51 @@ export function generatePackage(input: {
destIcao: dest.icao,
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType,
cargoCount,
cargoCount: finalCargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
createdSimTime: nowMs,
createdRealMs: input.createdRealMs ?? Date.now(),
};
}
// v0.65.0: one pool package — a forced origin (the pool airport), a within-cap random destination
// (same cap as generated legs, so every pool job is flyable by some catalog plane), and a weight drawn
// uniformly from the band's range. Shared by buildSeed and the runtime spawner so seeded and spawned
// packages are indistinguishable. RNG draws per package: dest, weight, then generatePackage's own order.
export function generatePoolPackage(input: {
airports: SeedAirport[];
originIcao: string;
bandIdx: number;
rng: Rng;
clockMs: number; // game time — anchors createdSimTime/deadlineSimTime
id: string;
createdRealMs?: number; // wall-clock stamp (default Date.now())
reputation?: number;
}): SeedPackage {
const { airports, originIcao, bandIdx, rng } = input;
const oi = airports.findIndex((a) => a.icao === originIcao);
if (oi < 0) throw new Error(`unknown pool airport ${originIcao}`);
const di = pickDestWithinCap(airports, oi, rng);
if (di < 0) throw new Error(`no within-cap destination from ${originIcao}`);
const band = POOL_BANDS[bandIdx];
const weightKg = Math.round(band.minKg + rng() * (band.maxKg - band.minKg));
return generatePackage({
id: input.id,
airports,
rng,
nowMs: input.clockMs,
originIcao,
destIcao: airports[di].icao,
weightKg,
createdRealMs: input.createdRealMs ?? Date.now(),
reputation: input.reputation,
});
}
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
@@ -286,22 +385,30 @@ export function buildSeed(input: {
health: 100, // full health on first run (v0.39.0, ISS-069)
};
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
// Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these.
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
// Stable index-based ids for the seed set; runtime-generated entities must use random UUIDs to avoid
// colliding with these. Deadlines are drawn relative to the GAME clock origin (ISS-117), not the real
// wall time, so they stay meaningful at the fixed world rate; createdRealMs is the REAL seed moment
// (input.nowMs) so seeded jobs get a fresh full wall-clock lifetime.
const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) {
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
let n = 1;
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
packages.push(
generatePoolPackage({ airports, originIcao: hub.icao, bandIdx, rng, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: nowMs }),
);
}
}
return {
plane,
packages,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
// by ~0 instead of treating "time since install" as elapsed play time.
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
// selected manually yet, so resume would restore 1× anyway.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
// ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
// is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
// install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
// stability — no game code reads them anymore.
simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
};
}
+62
View File
@@ -0,0 +1,62 @@
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,
expiresRealMs: status === 'open' ? 2_000_000 : null, // v0.65.0 — open jobs carry their wall-clock expiry
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']);
});
});
+64 -18
View File
@@ -5,6 +5,7 @@ import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
import { JOB_LIFETIME_MS } from './seed';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -49,6 +50,21 @@ export function parseIcaoQueue(raw: string | null): string[] {
}
}
// v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination
// joins the pool while airborne), or queued for it (queued legs count even while their plane is in
// the shop — the work will be waiting at its next stop). A fleet fully repairing with empty queues
// has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's
// pool spawner so both agree on exactly which airports keep open jobs.
export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set<string> {
const set = new Set<string>();
for (const p of planes) {
if (p.status === 'available') set.add(p.locationIcao);
else if (p.status === 'flying' && p.destIcao) set.add(p.destIcao);
for (const icao of parseIcaoQueue(p.queue)) set.add(icao);
}
return set;
}
export interface PackageState {
id: string;
originIcao: string;
@@ -62,16 +78,23 @@ export interface PackageState {
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
createdSimTime: number;
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
expiresRealMs: number | null;
}
export interface StateSnapshot {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
// only the clock itself is reported here.
simState: { clockMs: number };
planes: PlaneState[];
packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
@@ -81,6 +104,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
@@ -100,6 +133,17 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
// v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing.
const legDistanceKm = (originIcao: string, destIcao: string) => {
const from = coordsByIcao.get(originIcao);
const to = coordsByIcao.get(destIcao);
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
};
// v0.65.0: pool visibility — open packages appear only where a plane can take work (poolAirports);
// every other status is always visible (on-board cargo, history). Filtered rows are absent from the
// body entirely, so clients never see jobs they could not dispatch.
const pool = poolAirports(planeRows);
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock.
@@ -127,7 +171,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION,
@@ -151,21 +195,23 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
repairUntilSimTime: p.repairUntilSimTime,
};
}),
packages: packageRows.map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
})),
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
})),
};
}
+224 -2
View File
@@ -2,18 +2,39 @@ 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 }],
['EDDV', { lat: 52.46, lon: 9.68 }],
['EHAM', { lat: 52.31, lon: 4.76 }],
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
]);
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
const modDiff = (a: number, b: number): number => {
const d = Math.abs(a - b) % 360;
return Math.min(d, 360 - d);
};
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
// keeps MapLibre from drawing a chord across the map.
function expectContinuousLons(coords: [number, number][]): void {
for (let i = 1; i < coords.length; i++) {
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
}
}
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
return {
account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false },
simState: { clockMs: 0 },
planes: [],
packages: [],
catalog: [
@@ -27,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', () => {
@@ -38,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);
@@ -64,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');
});
@@ -75,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: [] });
@@ -320,3 +448,97 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
});
});
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
const arc = greatCircleArc(from, to);
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
let sum = 0;
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
});
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
const arc = greatCircleArc(to, from);
expectContinuousLons(arc);
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
});
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
expectContinuousLons(arc);
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
});
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
const coords = fc.features[0]!.geometry.coordinates;
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
const last = coords[coords.length - 1]!;
const nzch = AIRPORTS.get('NZCH')!;
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
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
});
});
+45 -5
View File
@@ -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,
@@ -74,16 +89,38 @@ export interface AirportCoord {
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
export const ROUTE_ARC_SEGMENTS = 24;
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
// 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++) {
const lon = unwrapLon(coords[i]![0], prev);
coords[i]![0] = lon;
prev = lon;
}
return coords;
}
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport
// coords (positionAt returns them verbatim at t=0 and t=1).
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
// ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
const coords: [number, number][] = [];
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
coords.push([pos.lon, pos.lat]);
}
return coords;
return unwrapLons(coords);
}
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
@@ -195,6 +232,9 @@ export function queuedRoutesGeoJson(
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
}
// ISS-120: each leg's arc is unwrapped 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) — re-unify.
unwrapLons(coords);
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
+5 -25
View File
@@ -6,6 +6,7 @@ describe('OpenAPI spec', () => {
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',
@@ -15,7 +16,6 @@ describe('OpenAPI spec', () => {
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
]);
@@ -136,6 +136,8 @@ describe('OpenAPI spec', () => {
'deadlineSimTime',
'destIcao',
'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'expiresRealMs', // v0.65.0 — wall-clock expiry for open jobs; null otherwise
'id',
'originIcao',
'originName',
@@ -146,36 +148,14 @@ describe('OpenAPI spec', () => {
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
// ISS-117 (v0.52.0): the snapshot carries only the game clock — time controls are gone.
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
});
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
const post = openApiSpec.paths['/api/sim'].post;
expect(post.operationId).toBe('setSimControls');
const requestSchema = post.requestBody.content['application/json'].schema;
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
const ok = post.responses[200].content['application/json'].schema;
expect(ok.$ref).toBe('#/components/schemas/SimState');
const bad = post.responses[400].content['application/json'].schema;
expect(bad.$ref).toBe('#/components/schemas/Error');
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
expect(controls.timeScale.type).toBe('number');
expect(controls.timeScale.minimum).toBe(1);
expect(controls.timeScale.maximum).toBe(1000);
expect(controls.paused.type).toBe('boolean');
const error = openApiSpec.components.schemas.Error.properties;
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) =>
+14 -43
View File
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. The world clock advances on a ~1 s background tick at a fixed rate (v0.52.0, ISS-117: no player-facing time controls), clamped per tick; while no plane is flying or in repair the clock stays frozen. Each call ticks first, so expiry/delivery/spawn are always applied before the snapshot is returned.',
operationId: 'getState',
responses: {
200: {
@@ -51,35 +51,17 @@ export const openApiSpec = {
},
},
},
'/api/sim': {
post: {
summary: 'Set sim time controls',
'/api/events': {
get: {
summary: 'Live state stream (SSE)',
description:
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.',
operationId: 'setSimControls',
requestBody: {
required: false,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimControlsRequest' },
},
},
},
'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: 'Updated sim state',
description: 'SSE stream of state snapshots (text/event-stream)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimState' },
},
},
},
400: {
description: 'Invalid body (not an object, unknown field, or wrong types)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
'text/event-stream': { schema: { type: 'string' } },
},
},
},
@@ -493,24 +475,9 @@ export const openApiSpec = {
SimState: {
type: 'object',
properties: {
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
timeScale: { type: 'number', minimum: 1, maximum: 1000 },
paused: { type: 'boolean' },
},
required: ['clockMs', 'timeScale', 'paused'],
},
SimControlsRequest: {
type: 'object',
description: 'Partial update — omit a field to leave it unchanged.',
properties: {
timeScale: {
type: 'number',
minimum: 1,
maximum: 1000,
description: 'Game-time multiplier; values outside [1, 1000] are clamped. The UI offers the ladder 1/2/5/10/25/50/100/250/500/1000.',
},
paused: { type: 'boolean' },
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time). The world runs at a fixed rate with no player-facing controls (v0.52.0, ISS-117).' },
},
required: ['clockMs'],
},
LoadPackageRequest: {
type: 'object',
@@ -633,6 +600,7 @@ export const openApiSpec = {
cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free.
@@ -640,6 +608,7 @@ export const openApiSpec = {
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
},
required: [
'id',
@@ -651,10 +620,12 @@ export const openApiSpec = {
'cargoType',
'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'deadlineSimTime',
'status',
'planeId',
'createdSimTime',
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
],
},
},
+286 -224
View File
@@ -10,26 +10,34 @@ 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 { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
clampTimeScale,
deliverPackages,
expirePackages,
expireLoadedPackages,
expireOpenPackages,
finishRepairs,
MAX_TICK_ELAPSED_MS,
MAX_TIME_SCALE,
MIN_TIME_SCALE,
reputationGainFor,
spawnCoveragePackages,
spawnOpenPackages,
shouldAdvanceTime,
spawnPoolPackages,
tickOnce,
WORLD_TIME_SCALE,
} from './engine';
// Engine tests run against throwaway DB files (openGameDb) with fake timers so
// Date.now() — the only wall-clock input to a tick — is fully controlled. The
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered
// by app/api/sim/route.test.ts and the e2e suite.
// 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).
// #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 })
.where(eq(schema.planes.id, 'plane-starter'));
}
let dir: string;
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
@@ -37,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 });
});
@@ -53,43 +65,74 @@ 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 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 });
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 5_000); // 1 s × 5
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
});
it('freezes the clock while paused but keeps tracking real time', async () => {
const inst = await freshDb('pause.db');
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
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 + 3_600_000); // an hour of real pause
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(T0); // 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);
// Unpause: resumes from where it stopped, with NO catch-up for the pause.
await inst.db.update(schema.simState).set({ paused: false });
// 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);
sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60
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);
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
const sim = await tickOnce(inst); // timeScale is the seeded 1
const sim = await tickOnce(inst); // timeScale is the seeded WORLD_TIME_SCALE
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * WORLD_TIME_SCALE); // exactly one clamped tick, not a fast-forward
vi.setSystemTime(T0); // reset for later tests in this file
});
it('does not advance when two ticks land in the same millisecond', async () => {
const inst = await freshDb('same-ms.db');
await makeWorldActive(inst);
vi.setSystemTime(T0 + 1_000);
const a = await tickOnce(inst);
const b = await tickOnce(inst); // no real time passed since `a`
@@ -97,39 +140,70 @@ describe('tickOnce — clock advance', () => {
});
});
describe('expirePackages', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db');
const clock = T0 + 100_000;
describe('expireLoadedPackages (sim clock)', () => {
it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry-loaded.db');
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
const clock = STARTING_CLOCK_MS + 100_000;
await inst.db.insert(schema.packages).values([
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
]);
const n = await expirePackages(inst, clock);
expect(n).toBe(2); // the open and the loaded one
const n = await expireLoadedPackages(inst, clock);
expect(n).toBe(1); // only the loaded one — open jobs live on the wall clock now (v0.65.0)
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
expect(byId.get('p-open-past')).toBe('expired');
expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
});
it('a deadline exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-edge.db');
const inst = await freshDb('expiry-loaded-edge.db');
await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS,
});
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive
expect(await expireLoadedPackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
});
});
describe('expireOpenPackages (v0.65.0 wall clock)', () => {
it('flips open packages past their real-time lifetime; fresh ones and terminal statuses never', async () => {
const inst = await freshDb('expiry-open.db');
// T0 is the fake Date.now() at DB open — seeded rows carry createdRealMs = T0, so they are FRESH
// (a full JOB_LIFETIME_MS away) and only the aged row below can expire.
await inst.db.insert(schema.packages).values([
{ id: 'p-open-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
{ id: 'p-open-fresh', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 },
{ id: 'p-delivered-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
]);
const n = await expireOpenPackages(inst, T0);
expect(n).toBe(1); // only the aged open one — loaded jobs are sim-clock territory now
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
expect(byId.get('p-open-aged')).toBe('expired');
expect(byId.get('p-open-fresh')).toBe('open'); // lifetime not reached yet
expect(byId.get('p-delivered-aged')).toBe('delivered'); // terminal — never flips
});
it('a lifetime exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-open-edge.db');
await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
createdRealMs: T0 - JOB_LIFETIME_MS,
});
expect(await expireOpenPackages(inst, T0)).toBe(1); // <= is inclusive
});
});
@@ -378,6 +452,52 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.status).toBe('available'); // unchanged landing behavior…
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
});
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
const wTo = weatherFor(TO);
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
);
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['FGSL']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
});
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
});
});
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
@@ -470,11 +590,13 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
const inst = await freshDb('repair-tick.db');
// The repairing plane keeps the world advancing (ISS-117); scale pinned to 1 for clean math.
await inst.db.update(schema.simState).set({ timeScale: 1 });
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + 1_500 })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock STARTING_CLOCK_MS+2000 ≥ gate
await tickOnce(inst);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -514,107 +636,65 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
});
});
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
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);
});
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
// any advancing tick past T0 finalizes it. timeScale is the value under test;
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
async function setupLanding(name: string, timeScale: number) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
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 });
expect(shouldAdvanceTime([{ status: 'repairing' }])).toEqual({ advance: true, flying: 0, repairing: 1 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
});
await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
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 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 () => {
const inst = await freshDb('idle-repair.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, km, type };
}
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
const { inst } = await setupLanding('slow-last.db', 1000);
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
expect(sim.timeScale).toBe(1); // …slowed to the floor…
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
});
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
const { inst, from, type } = await setupLanding('slow-other.db', 1000);
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
await inst.db.insert(schema.planes).values({
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0,
});
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS.
for (const p of await inst.db.select().from(schema.packages)) {
if (p.status === 'open') {
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id));
}
}
vi.setSystemTime(T0 + 2_000);
vi.setSystemTime(T0 + 1_000); // a background tick while the fleet is grounded
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
});
it('already at 1× → stays 1 (no-op slow)', async () => {
const { inst } = await setupLanding('slow-one.db', 1);
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
});
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
expect(plane.destIcao).toBe('EDDV');
});
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
const inst = await freshDb('slow-paused.db');
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
vi.setSystemTime(T0 + 5_000);
const sim = await tickOnce(inst);
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
expect(sim.timeScale).toBe(1000);
expect(sim.lastSelectedTimeScale).toBe(1000);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // freeze preserved…
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // …but the starter's pool is refilled to its full composition…
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // …all at the grounded starter's airport
});
});
describe('spawnOpenPackages', () => {
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
const inst = await freshDb('spawn.db');
describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
it('tops each pool airport up to the POOL_BANDS composition using the shared seed builder', async () => {
const inst = await freshDb('pool.db');
const clock = T0 + 123_456;
// Leave exactly 3 open packages (the rest of the seeded set is "done").
@@ -623,16 +703,18 @@ describe('spawnOpenPackages', () => {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
}
const needed = await spawnOpenPackages(inst, clock, mulberry32(7));
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3);
const needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0);
expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // single grounded plane → one pool
const fc = await loadAirportsGeoJson();
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
for (const p of openRows.slice(3)) { // the spawned ones
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
expect(p.createdRealMs).toBe(T0); // …and stamped with the injected wall clock
expect(icaoSet.has(p.originIcao)).toBe(true);
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
@@ -644,15 +726,36 @@ describe('spawnOpenPackages', () => {
}
// Already at target → nothing more to spawn.
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
expect(await spawnPoolPackages(inst, clock, mulberry32(7), T0)).toBe(0);
});
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
const a = await freshDb('spawn-a.db');
const b = await freshDb('spawn-b.db');
it('keeps the band composition per airport — weights stay inside their band', async () => {
const inst = await freshDb('pool-composition.db');
// Wipe all seeded packages so the spawner builds the pool from zero with a known RNG.
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT); // one pool (EDDF) at full composition
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
const byBand = [0, 0, 0, 0];
for (const p of openRows) {
const b = poolBandOf(p.weightKg);
byBand[b]++;
expect(p.weightKg).toBeGreaterThanOrEqual(POOL_BANDS[b].minKg); // weight stays in its band…
expect(p.weightKg).toBeLessThanOrEqual(POOL_BANDS[b].maxKg); // …on both ends
}
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
});
it('is deterministic for an injected RNG and wall clock', async () => {
const a = await freshDb('pool-a.db');
const b = await freshDb('pool-b.db');
const clock = T0 + 999;
// Wipe all packages in both so both spawn from zero with the same RNG.
// Wipe all packages in both so both spawn from zero with the same RNG and now.
for (const inst of [a, b]) {
const rows = await inst.db.select().from(schema.packages);
for (const r of rows) {
@@ -660,8 +763,8 @@ describe('spawnOpenPackages', () => {
}
}
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const strip = (rows: schema.PackageRow[]) =>
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
@@ -669,114 +772,73 @@ describe('spawnOpenPackages', () => {
strip(await b.db.select().from(schema.packages)),
);
});
});
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
// The seeded starter plane is grounded at EDDF with no seeded package from
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
it('spawns an open package originating at the airport of each uncovered grounded plane', async () => {
const inst = await freshDb('coverage.db');
const clock = T0 + 5_000;
// Wipe all seeded packages so coverage is the only source of new ones.
it('spawns for flying destinations and queued airports, not just grounded locations', async () => {
const inst = await freshDb('pool-membership.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
const n = await spawnCoveragePackages(inst, clock, mulberry32(7));
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(1);
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
expect(openRows[0].createdSimTime).toBe(clock);
});
it('does nothing when every grounded airport already has an open package', async () => {
const inst = await freshDb('coverage-covered.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
// One open package from EDDF covers the starter plane's airport.
await inst.db.insert(schema.packages).values({
id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
// Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is
// {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it.
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
await inst.db.insert(schema.planes).values({
id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
});
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0);
expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added
});
it('spawns one package per uncovered airport when planes sit at several airports', async () => {
const inst = await freshDb('coverage-multi.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
await inst.db.insert(schema.planes).values([
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null },
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null },
]);
const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7));
expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set
const origins = (await inst.db.select().from(schema.packages))
.filter((p) => p.status === 'open')
.map((p) => p.originIcao)
.sort();
expect(origins).toEqual(['EDDF', 'LEMD']);
expect(origins).toEqual(
[...Array(POOL_TARGET_PER_AIRPORT).fill('EHAM'), ...Array(POOL_TARGET_PER_AIRPORT).fill('LEMD')].sort(),
);
});
it('ignores flying planes — they cannot take new work until they land', async () => {
const inst = await freshDb('coverage-flying.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
it('holds taken slots for their refill delay, then refills them', async () => {
const inst = await freshDb('pool-cooldown.db');
// Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport
// cooldown — see the ponytail note in spawnPoolPackages).
const all = await inst.db.select().from(schema.packages);
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
// The small slot frees first (SMALL_REFILL_DELAY_MS) — exactly its missing count refills; the heavy
// one is still held by REFILL_DELAY_MS.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + SMALL_REFILL_DELAY_MS)).toBe(1);
// The heavy slot frees at REFILL_DELAY_MS — the last empty band tops up too.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + REFILL_DELAY_MS)).toBe(1);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // pool back to full composition
});
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db');
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => {
const inst = await freshDb('pool-tick.db');
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
// never lands here.
await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
});
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
await tickOnce(inst);
const fromEdff = (await inst.db.select().from(schema.packages)).filter(
(p) => p.status === 'open' && p.originIcao === 'EDDF',
);
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
});
});
describe('clampTimeScale — v0.14.0 ladder bounds [1, 1000]', () => {
it('clamps below-minimum values to MIN_TIME_SCALE', () => {
expect(clampTimeScale(0)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(-5)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(0.1)).toBe(MIN_TIME_SCALE);
});
it('clamps above-maximum values to MAX_TIME_SCALE', () => {
expect(clampTimeScale(1001)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(2000)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIME_SCALE);
});
it('passes in-range values through unclamped — ladder and off-ladder alike', () => {
for (const s of [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]) {
expect(clampTimeScale(s)).toBe(s); // the full v0.14.0 slider ladder
}
expect(clampTimeScale(37)).toBe(37); // off-ladder but in range (e.g. a legacy value)
});
it('keeps the endpoints exact', () => {
expect(clampTimeScale(MIN_TIME_SCALE)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(MAX_TIME_SCALE)).toBe(MAX_TIME_SCALE);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
});
});
+215 -185
View File
@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto';
import { eq, inArray } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
@@ -7,31 +7,41 @@ 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, poolAirports, sseFrame } from '@/lib/game/state';
import { broadcast } from '@/lib/sse';
import {
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
JOB_LIFETIME_MS,
POOL_BANDS,
REFILL_DELAY_MS,
SMALL_REFILL_DELAY_MS,
generatePoolPackage,
mulberry32,
pickDestWithinCap,
poolBandOf,
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
// (real ms since last tick) × timeScale, clamped per tick so long absences never
// fast-forward the game.
// 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) × 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 a normal client poll interval (~1 s), so active play advances
// ~real time × scale; small enough that one stale read after an absence moves game
// time by at most 2 s × scale (≈ 33 min even at max scale 1000). The sim therefore
// only meaningfully progresses while actively polled.
// 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
// ~real time × scale; small enough that one stale read after an absence moves game time by at most
// 2 s × scale.
export const MAX_TICK_ELAPSED_MS = 2_000;
export const MIN_TIME_SCALE = 1;
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0)
// 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,18 +53,15 @@ export function reputationGainFor(km: number): number {
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
}
export interface SimControlsInput {
timeScale?: number;
paused?: boolean;
}
// 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,
);
@@ -63,63 +70,70 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// #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/delivery/repair progress until a dispatch or
// repair starts again (the market top-up still runs on frozen ticks, #135 follow-up).
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0;
let repairing = 0;
for (const p of planes) {
if (p.status === 'flying') flying++;
else if (p.status === 'repairing') repairing++;
}
return { advance: flying > 0 || repairing > 0, flying, repairing };
}
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const rows = await inst.db.select().from(schema.simState);
const sim = rows[0];
if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now();
// #135: a fully grounded fleet freezes the world — no advance, no expiry/delivery/repair progress
// (the market top-up still runs on frozen ticks — see below). 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);
// While paused the clock is frozen; lastRealTickMs still tracks real time so
// unpausing resumes cleanly with no catch-up jump for the pause duration.
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
const clockMs = Math.round(sim.clockMs + advanceMs);
let landedThisTick = 0;
if (advanceMs > 0) {
await expirePackages(inst, clockMs);
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
landedThisTick = await deliverPackages(inst, clockMs);
await expireLoadedPackages(inst, clockMs);
await deliverPackages(inst, clockMs);
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets pool work too.
await finishRepairs(inst, clockMs);
await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned
// at its new location within the same tick.
await spawnCoveragePackages(inst, clockMs);
}
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
clockMs,
lastRealTickMs: nowMs,
};
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
if (landedThisTick > 0) {
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
}
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
// because while grounded nothing else creates packages but removal paths (ignore) still run; without
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
await expireOpenPackages(inst, nowMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned at its new location within
// the same tick (its destination joins the pool while flying, and its arrival airport on landing).
await spawnPoolPackages(inst, clockMs);
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
await inst.db.update(schema.simState).set(sets);
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing after tick');
return updated;
}
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
// occupying their slot, deliver no reward on arrival, and abandon for free).
// Delivered/abandoned packages are terminal and never flip. Returns the count.
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
// v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 —
// it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs
// expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and
// never flip. Both return the count flipped.
export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'loaded'));
let n = 0;
for (const p of rows) {
if (p.deadlineSimTime <= clockMs) {
@@ -130,6 +144,22 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n;
}
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). Returns the count flipped.
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'));
let n = 0;
for (const p of rows) {
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
n++;
}
}
return n;
}
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
@@ -174,8 +204,7 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
// remaining fuel and the queue kept intact for a later dispatch.
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
// leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
// Returns planes landed.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes),
@@ -246,32 +275,43 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0 && newHealth > 0) {
const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
} else {
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
// zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
if (nextDest === to.icao) {
queue.shift();
} else {
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
// v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
// intact, mirroring flyPlane's 409 without throwing in the engine.
if (nextRequiredL > type.maxFuel) {
// no chain — the grounded update below keeps the queue for a later dispatch
} else if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
} else {
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
}
}
}
@@ -308,19 +348,28 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
return n;
}
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
// SAME builder as the first-run seed (generatePackage), so spawned packages are
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
export async function spawnOpenPackages(
// v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded/ignored) stays empty for the band's refill delay after
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
export async function spawnPoolPackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
nowMs?: number,
): Promise<number> {
const openCount = (
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
).length;
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
if (needed <= 0) return 0;
const real = nowMs ?? Date.now();
const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes),
inst.db.select().from(schema.packages),
]);
const pool = poolAirports(planeRows);
if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({
@@ -334,82 +383,77 @@ export async function spawnOpenPackages(
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
for (let i = 0; i < needed; i++) {
await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
);
}
return needed;
}
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
// package originating there — so work is always available where the fleet sits
// (including onboarding at EDDF). One package per uncovered airport, generated
// with generatePackage and origin forced to that airport. Runs after
// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage
// too; and after deliverPackages, so a plane that just landed gets work spawned
// at its new location within the same tick. Returns the number created.
export async function spawnCoveragePackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
): Promise<number> {
const [planeRows, openRows] = await Promise.all([
inst.db.select().from(schema.planes),
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')),
]);
// Distinct airports with at least one grounded plane (flying planes don't count —
// they can't take new work until they land).
const groundedIcas = [
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
];
if (groundedIcas.length === 0) return 0;
const covered = new Set(openRows.map((c) => c.originIcao));
const missing = groundedIcas.filter((icao) => !covered.has(icao));
if (missing.length === 0) return 0;
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,
}));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
let n = 0;
for (const icao of missing) {
const oi = airports.findIndex((a) => a.icao === icao);
if (oi < 0) continue; // defensive — the dataset is the source of all ICACs
// v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs —
// one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged.
const di = pickDestWithinCap(airports, oi, rand);
await inst.db.insert(schema.packages).values(
generatePackage({
id: randomUUID(),
airports,
rng: rand,
nowMs: clockMs,
originIcao: icao,
...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior
reputation: account.reputation,
}),
);
n++;
for (const originIcao of pool) {
// Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter:
// terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp.
const openByBand = [0, 0, 0, 0];
let blockedUntil: number | null = null;
for (const p of packageRows) {
if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue;
const b = poolBandOf(p.weightKg);
if (p.status === 'open') openByBand[b]++;
else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) {
// ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job
// refills too. Per-band tracking is a trivial change if the board feels sluggish.
blockedUntil = p.slotFreeAtRealMs;
}
}
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS;
while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) {
await inst.db.insert(schema.packages).values(
generatePoolPackage({
airports,
originIcao,
bandIdx,
rng: rand,
clockMs,
id: randomUUID(),
createdRealMs: real,
reputation: account.reputation,
}),
);
openByBand[bandIdx]++;
n++;
}
}
}
return n;
}
// --- 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
// tick path as reads/actions. Started once at server boot from instrumentation.ts (Next ≥15); the
// guard makes double-starts harmless (dev HMR reloads).
let backgroundTimer: ReturnType<typeof setInterval> | null = null;
export function startBackgroundTick(intervalMs = 1_000): void {
if (backgroundTimer !== null) return;
backgroundTimer = setInterval(() => {
tickNow().catch((err) => console.error('flight: background tick failed', err));
}, intervalMs);
}
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
@@ -425,31 +469,17 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
});
}
// Tick first so the returned state reflects current time, then apply the new
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
// the last grounded plane takes off. A {paused}-only post never touches it, so the
// last manual intent wins without an extra flag.
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
// Test-hook backing (FLIGHT_TEST_HOOKS=1 only — see app/api/test/set-timescale/route.ts): set the
// effective time scale under the same lock as ticks so a background tick can never interleave
// between read and write. Input validation happens in the route; this trusts its number.
export function setTestTimeScale(scale: number): Promise<schema.SimStateRow> {
return withLock(async () => {
const inst = await getGameDb();
await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) {
const scale = clampTimeScale(input.timeScale);
sets.timeScale = scale;
sets.lastSelectedTimeScale = scale;
}
if (input.paused !== undefined) sets.paused = input.paused;
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
await inst.db.update(schema.simState).set({ timeScale: scale });
await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing');
return updated;
});
}
export function clampTimeScale(v: number): number {
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
}
+21
View File
@@ -5,6 +5,8 @@ import {
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
guaranteedRangeKm,
MAX_WIND_BURN_FACTOR,
maxRangeKm,
positionAt,
progressAt,
@@ -234,3 +236,22 @@ describe('maxRangeKm (v0.28.0)', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => {
it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => {
// Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy.
expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90));
});
it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => {
const r = guaranteedRangeKm(BURN, WE, TANK);
expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12);
expect(r).toBeGreaterThan(3696 * 0.95);
expect(r).toBeLessThan(3696 * 1.05);
});
it('zero fuel → zero range; rejects a non-positive empty weight', () => {
expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0);
expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow();
});
});
+13
View File
@@ -137,3 +137,16 @@ export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: nu
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of
// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150.
export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150;
// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case
// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor
// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what
// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard
// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn).
export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel);
}
+45
View File
@@ -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
View File
@@ -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
}
}
}
-81
View File
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest';
import { MAX_SCALE, MIN_SCALE, TIME_SCALES, scaleToSlider, sliderToScale } from './timeScale';
const STEPS = TIME_SCALES.length - 1; // 9 intervals across the 10 ladder positions (v0.14.0)
describe('sliderToScale', () => {
it('is exact at the endpoints (0 → 1×, 1 → 1000×)', () => {
expect(sliderToScale(0)).toBe(MIN_SCALE);
expect(sliderToScale(1)).toBe(MAX_SCALE);
});
it('maps each of the 10 positions to the ladder', () => {
const expected = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000];
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(sliderToScale(i / STEPS)).toBe(expected[i]);
}
});
it('is monotonic non-decreasing across the whole range', () => {
let prev = sliderToScale(0);
for (let i = 1; i <= 2000; i++) {
const s = sliderToScale(i / 2000);
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
});
it('only ever returns a ladder value', () => {
for (let i = 0; i <= 2000; i++) {
expect(TIME_SCALES).toContain(sliderToScale(i / 2000));
}
});
it('clamps out-of-range and non-finite input', () => {
expect(sliderToScale(-0.5)).toBe(MIN_SCALE);
expect(sliderToScale(1.5)).toBe(MAX_SCALE);
expect(sliderToScale(NaN)).toBe(MIN_SCALE);
});
});
describe('scaleToSlider', () => {
it('is exact at the endpoints (1× → 0, 1000× → 1)', () => {
expect(scaleToSlider(1)).toBe(0);
expect(scaleToSlider(1000)).toBe(1);
});
it('places each ladder value on its own position', () => {
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(scaleToSlider(TIME_SCALES[i])).toBeCloseTo(i / STEPS, 12);
}
});
it('stays within [0, 1] for in-range scales', () => {
for (let s = MIN_SCALE; s <= MAX_SCALE; s++) {
const t = scaleToSlider(s);
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
}
});
it('clamps out-of-range and non-finite input', () => {
expect(scaleToSlider(0)).toBe(0);
expect(scaleToSlider(-3)).toBe(0);
expect(scaleToSlider(1001)).toBe(1); // above the ladder top → clamped to 1000× → t=1
expect(scaleToSlider(NaN)).toBe(0);
});
});
describe('round-trip', () => {
it('sliderToScale(scaleToSlider(s)) === s for every ladder value', () => {
for (const s of TIME_SCALES) {
expect(sliderToScale(scaleToSlider(s))).toBe(s);
}
});
it('maps off-ladder scales to the nearest ladder step by log distance', () => {
expect(sliderToScale(scaleToSlider(3))).toBe(2); // ln 3 ≈ 1.10 → closer to 2 (ln ≈ 0.69) than 5 (ln ≈ 1.61)
expect(sliderToScale(scaleToSlider(60))).toBe(50); // legacy max from the v0.9.x era sits between 50 and 100, nearer 50
expect(sliderToScale(scaleToSlider(200))).toBe(250); // ln 200 ≈ 5.30 → closer to 250 (ln ≈ 5.52) than 100 (ln ≈ 4.61)
});
});
-33
View File
@@ -1,33 +0,0 @@
// Pure mapping helpers for the time-scale slider (v0.9.2, values reworked in v0.14.0) — no
// game logic, unit-testable in node. The slider has 10 discrete positions on a mixed
// ×2/×2.5 ladder: 1× → 2× → 5× → 10× → 25× → 50× → 100× → 250× → 500× → 1000×, so each
// step roughly doubles the speed (matching POST /api/sim's clamp range [1, 1000]).
export const TIME_SCALES = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000] as const;
export const MIN_SCALE: number = TIME_SCALES[0];
export const MAX_SCALE: number = TIME_SCALES[TIME_SCALES.length - 1];
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
// t → scale: the position maps to the nearest ladder step. Exact at the endpoints:
// sliderToScale(0) === 1, sliderToScale(1) === 1000.
export function sliderToScale(t: number): number {
const tt = clamp(Number.isFinite(t) ? t : 0, 0, 1);
return TIME_SCALES[clamp(Math.round(tt * (TIME_SCALES.length - 1)), 0, TIME_SCALES.length - 1)];
}
// scale → t: the nearest ladder position by log distance. Exact at the endpoints:
// scaleToSlider(1) === 0, scaleToSlider(1000) === 1; round-trips every value in TIME_SCALES.
export function scaleToSlider(scale: number): number {
const s = clamp(Number.isFinite(scale) ? scale : MIN_SCALE, MIN_SCALE, MAX_SCALE);
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < TIME_SCALES.length; i++) {
const d = Math.abs(Math.log(s) - Math.log(TIME_SCALES[i]));
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best / (TIME_SCALES.length - 1);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { fetchState } from './useGameState';
const SNAPSHOT = {
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
simState: { clockMs: 1_750_000_000_000 },
planes: [],
packages: [],
};
+57 -7
View File
@@ -28,15 +28,21 @@ export async function fetchState(): Promise<StateSnapshot> {
(window as unknown as Record<string, unknown>).__flightLastSnapshot = {
planeIds: snapshot.planes.map((p) => p.id),
cash: snapshot.account.cash,
timeScale: snapshot.simState.timeScale,
clockMs: snapshot.simState.clockMs,
at: Date.now(),
};
}
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 };
}
+17 -20
View File
@@ -1,49 +1,46 @@
import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
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 × timeScale, frozen while paused.
// 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,
at: 1_000_000,
timeScale: 1,
paused: false,
advancing: true,
...over,
});
describe('advanceSimClock', () => {
it('advances by exactly elapsed real-time × timeScale while running', () => {
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000); // scale 1: 1 s real = 1 s game
expect(advanceSimClock(anchor({ timeScale: 25 }), 1_002_500)).toBe(1_750_000_000_000 + 62_500);
// The e2e scenario: scale 1000, 2.5 s real → 2 500 game seconds.
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_002_500)).toBe(1_750_000_000_000 + 2_500_000);
it('advances by exactly elapsed real-time × WORLD_TIME_SCALE while running', () => {
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000 * WORLD_TIME_SCALE);
// The e2e scenario: ~2.6 s real at the fixed world rate.
expect(advanceSimClock(anchor(), 1_002_600)).toBe(1_750_000_000_000 + 2_600 * WORLD_TIME_SCALE);
});
it('returns the anchor value exactly at zero elapsed', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_000_000)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
});
it('is frozen while paused regardless of elapsed or scale', () => {
const a = anchor({ timeScale: 1000, paused: true });
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
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); // 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', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor({ paused: true }), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor(), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor({ advancing: false }), 999_999)).toBe(1_750_000_000_000);
});
it('mirrors the server tick model (tickOnce): advance = elapsed × timeScale while actively polled', () => {
it('mirrors the server tick model (tickOnce): advance = elapsed × WORLD_TIME_SCALE while actively polled', () => {
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
const scale = 50;
for (const gap of [1, 250, 999, 1_999]) {
expect(advanceSimClock(anchor({ timeScale: scale }), 1_000_000 + gap)).toBe(
1_750_000_000_000 + gap * scale,
);
expect(advanceSimClock(anchor(), 1_000_000 + gap)).toBe(1_750_000_000_000 + gap * WORLD_TIME_SCALE);
}
});
});
+20 -26
View File
@@ -2,57 +2,51 @@
import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
import { WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × timeScale
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
// (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.
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
// 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.
clockMs: number;
// Date.now() when that snapshot was received.
at: number;
timeScale: number;
paused: boolean;
// Whether the world clock is advancing (any plane flying or in repair).
advancing: boolean;
}
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to
// the anchor value instead of going backwards. Unit-testable in node.
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
// 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.paused) return anchor.clockMs;
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale;
if (!anchor.advancing) return anchor.clockMs; // frozen while everything is grounded (#135)
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
}
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
const DISPLAY_TICK_MS = 100;
export interface SimClockControls {
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
// UI changes take effect on click/drag without waiting for a confirming snapshot.
timeScale: number;
paused: boolean;
}
// Returns the display clockMs to render, or null until the first snapshot arrives.
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null {
export function useSimClock(snapshot: StateSnapshot | null): number | null {
const anchorRef = useRef<SimClockAnchor | null>(null);
const [displayMs, setDisplayMs] = useState<number | null>(null);
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot
// object is fresh per poll (useGameState), so this runs once per poll cycle as well.
// Re-sync on every snapshot: snap to server truth. The snapshot object is fresh per poll
// (useGameState), so this runs once per poll cycle as well.
useEffect(() => {
if (!snapshot) return;
anchorRef.current = {
clockMs: snapshot.simState.clockMs,
at: Date.now(),
timeScale: controls.timeScale,
paused: controls.paused,
};
// 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()));
}, [snapshot, controls.timeScale, controls.paused]);
}, [snapshot]);
useEffect(() => {
const id = setInterval(() => {
+10
View File
@@ -0,0 +1,10 @@
// ISS-117 (v0.52.0): the world clock runs at a fixed rate — no player-facing time controls.
// 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
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.45.0",
"version": "0.67.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
+54 -18
View File
@@ -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(', ')}`);