Compare commits

..
53 Commits
Author SHA1 Message Date
kamilkrzywda ff432da97e Merge pull request 'v1.12.0: takeoff & landing phases on the marker' (#166) from feat/takeoff-landing-phases into master
CI / build + unit + e2e (push) Successful in 6m19s
2026-09-28 12:15:36 +02:00
kamilkrzywda 37bdf902cc fix(e2e): fast-forward the phases spec at MID scale; retry set-health on transient 500
CI / build + unit + e2e (pull_request) Successful in 7m27s
2026-09-28 10:08:07 +00:00
kamilkrzywda b683c4e546 fix(e2e): pass STARTER into evaluate; verify self-heal end state on fresh snapshots
CI / build + unit + e2e (pull_request) Failing after 10m42s
2026-09-28 09:53:07 +00:00
kamilkrzywda 82e4c64206 v1.12.0 — takeoff & landing phases on the marker
CI / build + unit + e2e (pull_request) Failing after 7m29s
Every leg gains a 10-min takeoff phase at the origin and a 10-min
landing phase at the destination, each weather-scaled up to 2x via a
monotone condition rule (Clear < Cloudy < Rain < Fog). Single
composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor,
MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route
through it (progress is now piecewise — hold at origin / linear cruise
/ hold at destination), so engine arrival gate, client position/fuel
recompute and the job expiry floor all agree. Phases burn no fuel.

Marker shows the current phase next to the status in the popup
(mapData derives it from departureSimTime + clockMs; new
data-testid=plane-phase sibling). plane-status keeps the raw status.

Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest
composition, piecewise progressAt), engine.test.ts (deleted local travelMs
mirror — all ~18 fixture clocks use the real composition), seed.test.ts
(phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts
'taking off' at the origin gate and 'landing' at the destination gate on
EDDF→LEMD using the same legPhases boundaries as the engine.
2026-09-28 07:09:19 +00:00
kamilkrzywda db5ad639fb Merge pull request 'v1.11.0: Do not show jobs for planes that are in air (ISS-159)' (#165) from feat/hide-in-air-jobs into master
CI / build + unit + e2e (push) Successful in 5m27s
2026-09-28 01:02:03 +02:00
kamilkrzywda 7f28666f2e v1.11.0: hide open-package rows with no grounded plane at the origin (ISS-159)
CI / build + unit + e2e (pull_request) Successful in 5m25s
2026-09-27 22:56:16 +00:00
kamilkrzywda 5093fba82e Merge pull request 'v1.10.0: top-bar Log out (ISS-157) + Admin button gated to admins (ISS-158)' (#164) from feat/topbar-logout-admin-gate into master
CI / build + unit + e2e (push) Successful in 5m22s
2026-09-28 00:37:50 +02:00
kamilkrzywda cde4334946 v1.10.0: top-bar Log out (ISS-157) + Admin button gated to admins (ISS-158)
CI / build + unit + e2e (pull_request) Successful in 6m18s
2026-09-27 22:31:11 +00:00
kamilkrzywda 127d8f97f0 Merge pull request 'v1.9.0: e2e relocation speedup — FAST rate for self-heal cleanup legs' (#163) from feat/e2e-relocation-speedup into master
CI / build + unit + e2e (push) Successful in 6m15s
2026-09-27 23:03:46 +02:00
kamilkrzywda 479c5448f7 e2e: relocation self-heal legs at FAST rate — pure arrival waits (v1.9.0)
CI / build + unit + e2e (pull_request) Successful in 5m31s
2026-09-27 20:58:05 +00:00
kamilkrzywda d2b799e572 Merge pull request 'v1.8.0: e2e speedup — per-test timescale rates (FAST/CHAIN)' (#162) from feat/e2e-speedup into master
CI / build + unit + e2e (push) Successful in 6m0s
2026-09-27 22:34:27 +02:00
kamilkrzywda 664906b8ce e2e: per-test timescale speedup — FAST/CHAIN rates for pure arrival waits (v1.8.0)
CI / build + unit + e2e (pull_request) Successful in 6m3s
- actions/hover/two-planes: FAST=10_000 for expiry fast-forwards, landing
  waits and fly-home cleanups; queue: + CHAIN=4_000 for auto-chain
  observation, refuel test at explicit 2_000 with relaxed fuel bound
- map-interactions: FAST=50_000 chain hops (~3 min -> ~15 s per direction);
  crossing leg stays at SCALE and now sets it BEFORE dispatch (a leftover
  fast rate would let the dispatch tick consume most of the leg)
- hover fleet-row test asserts flying from the fly endpoint's own snapshot
  and freezes immediately instead of speeding up then freezing mid-leg
- two-planes parallel-flight: both-grounded poll after cleanup dispatches so
  afterEach heal no longer pays the range-edge return leg at SCALE
2026-09-27 20:18:56 +00:00
kamilkrzywda b60006a949 Merge pull request 'v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)' (#161) from feat/multiplayer-test-coverage into master
CI / build + unit + e2e (push) Successful in 13m51s
2026-09-26 22:40:02 +02:00
kamilkrzywda fe84d9a0bb v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)
CI / build + unit + e2e (pull_request) Successful in 13m33s
2026-09-26 20:26:10 +00:00
kamilkrzywda 43c4bb97a9 Merge pull request 'v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115)' (#160) from feat/multiplayer-leaderboard into master
CI / build + unit + e2e (push) Successful in 14m16s
2026-09-26 21:52:36 +02:00
kamilkrzywda b04e504263 v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115)
CI / build + unit + e2e (pull_request) Successful in 14m2s
2026-09-26 19:38:25 +00:00
kamilkrzywda d87d848ca2 Merge pull request 'v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)' (#156) from feat/multiplayer-spawn-quality into master
CI / build + unit + e2e (push) Successful in 13m34s
2026-09-26 21:21:52 +02:00
kamilkrzywda eae680c002 v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)
CI / build + unit + e2e (pull_request) Successful in 13m53s
2026-09-26 19:07:50 +00:00
kamilkrzywda c3fa5bf613 Merge pull request 'v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)' (#155) from feat/multiplayer-snapshot-market into master
CI / build + unit + e2e (push) Successful in 14m12s
2026-09-26 20:51:48 +02:00
kamilkrzywda fe60bd0482 v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)
CI / build + unit + e2e (pull_request) Successful in 14m29s
2026-09-26 18:37:11 +00:00
kamilkrzywda db77f03ce1 Merge pull request 'v1.3.0: per-player money & reputation in engine + actions (ISS-110)' (#154) from feat/multiplayer-per-player-economy into master
CI / build + unit + e2e (push) Successful in 13m45s
2026-09-26 19:50:20 +02:00
kamilkrzywda d00a012c4b v1.3.0: per-player money & reputation in engine + actions (ISS-110)
CI / build + unit + e2e (pull_request) Successful in 13m20s
2026-09-26 17:03:15 +00:00
kamilkrzywda 306f81aec6 Merge pull request 'v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)' (#153) from feat/multiplayer-ownership into master
CI / build + unit + e2e (push) Successful in 13m22s
2026-09-26 18:41:06 +02:00
kamilkrzywda ec26f9f366 v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s
2026-09-26 16:27:00 +00:00
kamilkrzywda ba2c36a3f8 Merge pull request 'v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)' (#152) from feat/multiplayer-auth-guard into master
CI / build + unit + e2e (push) Successful in 14m39s
2026-09-26 17:43:19 +02:00
kamilkrzywda 81ba642ec5 fix: add missing sign-in input testids (ISS-107) CI
CI / build + unit + e2e (pull_request) Successful in 14m20s
2026-09-26 15:28:54 +00:00
kamilkrzywda b0f1bd8b67 v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s
2026-09-26 14:40:56 +00:00
kamilkrzywda 21dd37f88c Merge pull request 'v1.0.0: players table + migration from singleton account (ISS-105)' (#151) from feat/multiplayer-players-table into master
CI / build + unit + e2e (push) Successful in 13m33s
v1.0.0: players table + migration from singleton account (ISS-105)
2026-09-24 16:41:52 +02:00
kamilkrzywda b2034ebc52 v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s
2026-09-24 14:26:34 +00:00
kamilkrzywda 837aa60a7d Merge pull request 'v0.73.0: remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
CI / build + unit + e2e (push) Successful in 13m46s
Merge pull request 'v0.73.0 — remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
2026-09-24 14:39:29 +02:00
kamilkrzywda 7b6a942e0c v0.73.0: remove new-package toast notifications
CI / build + unit + e2e (pull_request) Successful in 13m31s
2026-09-24 12:25:27 +00:00
kamilkrzywda 1a1834abc7 Merge pull request 'v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call' (#149) from fix/admin-login-hash into master
CI / build + unit + e2e (push) Successful in 13m14s
v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
2026-09-24 13:59:28 +02:00
kamilkrzywda b5bb0d01ce v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
CI / build + unit + e2e (pull_request) Successful in 14m7s
2026-09-24 11:45:06 +00:00
kamilkrzywda fe23c9ff0f Merge pull request 'v0.71.0 Admin panel — users table, login/sessions, generic DB CRUD modal' (#148) from feat/admin-panel into master
CI / build + unit + e2e (push) Successful in 13m21s
2026-09-24 12:11:46 +02:00
kamilkrzywda e24a5788c0 v0.71.0: fix admin e2e — pass sessionId into page.evaluate
CI / build + unit + e2e (pull_request) Successful in 15m37s
2026-09-24 09:56:05 +00:00
kamilkrzywda b89d351587 v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
2026-09-24 09:24:31 +00:00
kamilkrzywda 034d7a8d94 Merge pull request 'v0.70.0 Default map view: whole world at [0,0]' (#147) from chore/map-default-view into master
CI / build + unit + e2e (push) Successful in 13m59s
v0.70.0 — Default map view: whole world at [0,0] (#147)
2026-09-24 01:49:27 +02:00
kamilkrzywda 0aca4d63aa v0.70.0: e2e wheel-clamp test asserts MapLibre height fit floor
CI / build + unit + e2e (pull_request) Successful in 14m5s
2026-09-23 23:35:22 +00:00
kamilkrzywda fb369a1afb v0.70.0: default map view — world at [0,0] zoom 2, minZoom 0
CI / build + unit + e2e (pull_request) Failing after 14m13s
2026-09-23 22:53:21 +00:00
kamilkrzywda 78961fdb36 Merge pull request 'v0.69.0 Remove job ignore feature' (#146) from chore/remove-job-ignore into master
CI / build + unit + e2e (push) Successful in 14m3s
v0.69.0 — Remove job ignore feature (#146)
2026-09-24 00:16:58 +02:00
kamilkrzywda d2defe4cd3 v0.69.0: remove job ignore feature
CI / build + unit + e2e (pull_request) Successful in 14m31s
2026-09-23 22:01:39 +00:00
kamilkrzywda 7d117f6809 Merge pull request 'Special airports re-curation — one per ~1000 km region (ISS-144)' (#145) from fix/special-airports-curation into master
CI / build + unit + e2e (push) Successful in 13m14s
Special airports re-curation — one per ~1000 km region (ISS-144)
2026-09-23 22:47:08 +02:00
kamilkrzywda 49dfd2ca6e v0.68.0: re-curate special remote airports — one per ~1000 km region (ISS-144)
CI / build + unit + e2e (pull_request) Successful in 13m18s
Greedy sparse selection replaces the blanket 1000km-from-large filter that
over-filled central Australia / Canada edges with dozens of tiny strips:
candidates sorted by scheduled service, type rank (medium < small < seaplane),
numeric id; accepted only if >= REMOTE_RADIUS_KM from every already-accepted
special. CSV: 1483 -> 1161 rows (1119 large unchanged, specials 364 -> 42).
New unit test pins the pairwise-sparsity invariant on the committed dataset.
2026-09-23 20:33:25 +00:00
kamilkrzywda 65cbcf6e17 Merge pull request 'v0.67.0 — Prune runner dind leftovers after e2e' (#143) from ci/dind-prune into master
CI / build + unit + e2e (push) Successful in 14m25s
v0.67.0 — Prune runner dind leftovers after e2e
2026-09-23 20:21:48 +02:00
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
105 changed files with 6400 additions and 1426 deletions
+58 -2
View File
@@ -189,10 +189,56 @@ jobs:
# in-network app host out of any proxy path. # 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
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). # Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress # --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
# visibility in the job log; failure error context/snapshots are unchanged. # visibility in the job log; failure error context/snapshots are unchanged. The json
run: npx playwright test --reporter=list # 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. # 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). # -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
@@ -201,3 +247,13 @@ jobs:
run: | run: |
docker compose -f docker-compose.ci.yml -p flight-ci down --remove-orphans -v || true 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 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"
+118
View File
@@ -1,5 +1,123 @@
# Changelog # Changelog
## v1.12.0 — 2026-09-28 06:59
- **Takeoff & landing phases on the marker.** Every leg now has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the destination, on top of the pure cruise time (`lib/sim/movement.ts`): new `BASE_PHASE_MS`, `phaseFactor(weather)` ∈ [1, 2] (monotone over Clear < Cloudy < Rain < Fog — clear stays at 1×, Fog is exactly 2×), and `legPhases(distanceKm, speedKmh, originIcao, destIcao) → {takeoffMs, cruiseMs, landingMs, totalMs}` as the single composition. Takeoff scales on ORIGIN weather, landing on DESTINATION weather — both deterministic via `weatherFor(icao)`, so no stored state. The engine's arrival gate (`lib/sim/engine.ts`), the client's live position/fuel recompute (`lib/game/state.ts`) and the job expiry floor (`lib/game/seed.ts`) all route through it; `progressAt` is now piecewise — 0 while holding at the origin during takeoff, linear over cruise, 1 while holding at the destination during landing — so markers park at each gate instead of crawling along the whole route. The phases burn no fuel (pure time); the weighted-burn model is untouched.
- **Marker phase label.** `lib/mapData.ts` derives a phase label client-side from `departureSimTime + clockMs + legPhases` (all already in the snapshot) and the plane popup shows it next to the status — "flying · taking off" / "flying · cruising" / "flying · landing" (`components/Map.tsx`, new `data-testid="plane-phase"` sibling; `plane-status` keeps the raw status so existing assertions stay exact).
- **Tests.** Unit: `lib/sim/movement.test.ts` — pinned cruise duration unchanged, new suites for `phaseFactor` bounds/monotonicity (with `MAX_PHASE_FACTOR` pinned to the factor function like `MAX_WIND_BURN_FACTOR`), origin-vs-dest weather composition on a non-vacuous Rain/Cloudy pair, and piecewise `progressAt` holds; `lib/sim/engine.test.ts` — deleted the local `travelMs` mirror, all ~18 "exactly at arrival" fixture clocks now use the real `arrivalTimeFor(...)` composition (the wear-clamp second leg chains off leg 1's exact phase-inclusive arrival); `lib/game/seed.test.ts` expiry-floor mirrors updated to the phase-inclusive floor. E2E: new `e2e/phases.spec.ts` — dispatches the starter on EDDF→LEMD and, using the same `legPhases` boundaries as the engine, asserts the popup shows "taking off" while the plane holds at the origin gate, "landing" while it holds at the destination gate (scale dropped to 30× so each 600–1200 game-s window is observable), then "available" at Madrid; relocates home and restores the world rate.
## v1.11.0 — 2026-09-27 22:55
- **Do not show jobs for planes that are in air (ISS-159).** The right pane's open-package list now hides a row while no GROUNDED plane sits at the package's origin (`components/RightPane.tsx`): new exported `visibleOpenPackages(state)` replaces the bare `status === 'open'` filter — a job whose only claimant is still en route disappears from the list and reappears on landing. Payload-blocked rows (a grounded plane at the origin with no room) stay visible with their disabled Load button, exactly as before. Display-only: `poolAirports` and the spawner are untouched, so world state — jobs waiting at a flying plane's destination — is unchanged; this only stops rendering rows the player cannot dispatch yet. Known pre-existing gap (NOT fixed here): `loadEligiblePlanes` ignores plane ownership per the v1.4.0 shared-market design, so another player's grounded plane at an origin can make Load enabled for you → 403 on click.
- **Tests.** Unit: new `visibleOpenPackages` suite in `components/right-pane.test.ts` — grounded plane at origin → visible; only a flying plane there → hidden; grounded elsewhere + one flying to the origin → hidden; payload-blocked grounded plane at origin → still visible with Load disabled. E2E: new test in `e2e/right-pane.spec.ts` dispatches the only plane toward an airport, creates a job AT that destination (in the pool while airborne, so it reaches `/api/state`), and asserts no row renders until landing — pinning the client-side filter rather than the v0.65.0 server-side pool visibility.
## v1.10.0 — 2026-09-27 22:31
- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn).
- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button.
- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion).
## v1.9.0 — 2026-09-27 20:57
- **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is.
## v1.8.0 — 2026-09-27 20:13
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
## v1.7.0 — 2026-09-26 20:24
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
## v1.6.0 — 2026-09-26 19:35
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
## v1.5.0 — 2026-09-26 19:06
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
## v1.4.0 — 2026-09-26 18:35
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
## v1.3.0 — 2026-09-26 17:05
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths.
## v1.2.0 — 2026-09-26 16:25
- **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had.
- **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane.
- **Mint hook default → kamilkrzywda.** The e2e specs play with the seeded world's planes by id (`plane-starter`), so the no-body mint now resolves to the existing local player that owns them after backfill — creating NO new rows; a custom username still creates a fresh account, now WITH onboarding. `e2e/auth.ts` and every spec are untouched: they keep working because the session they mint is the owner's.
- **OpenAPI.** The seven plane-targeted action endpoints document the new 403 (buy-plane has no plane target, so it gains none); path count unchanged at eighteen — `openapi.test.ts`'s per-endpoint error-code lists updated to match.
- **Tests.** Unit: new backfill migration test in `lib/db/client.test.ts` builds a pre-v1.2.0 file (two planes without owner_id) and verifies open() backfills both to `'local'`, does not re-seed, and is a no-op on second open (a post-migration owner change survives reopen); new ownership suite in `lib/game/actions.test.ts` — 403 for every action when the session player doesn't own the plane (both directions), per-owner last-plane guard against the world total, buyer stamped as owner with their own cash debited and local's untouched; register + mint route tests assert exactly one onboarding plane at EDDF owned by the new account. All 74 existing action call sites in `actions.test.ts` now pass `'local'`; no e2e spec changes needed (the minted session is the owner's).
## v1.1.0 — 2026-09-26 14:39
- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook.
- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip.
- **Sign-in screen replaces the shell until authenticated.** New `components/SignInScreen.tsx`: full-screen dark panel with two forms (sign in / create account) sharing one submit path; errors surface in a single `role=alert` line. `app/page.tsx` gates on it: stored session → `/api/me` check on mount (stale id cleared, network error falls through to sign-in), then the game shell mounts only when authenticated — so an unauthenticated browser never polls `/api/state`. New client module `lib/authClient.ts` owns the localStorage key (`flight.session`) and builds the `X-Flight-Session` header; every app fetch (state poll, SSE URL, action POSTs, fly/queue in Map) now carries it. Admin panel logout drops back to the sign-in screen via a new `onLogout` prop (page → Map → AdminPanel).
- **E2E session hook + spec updates.** New test-only `POST /api/test/mint-player-session` (FLIGHT_TEST_HOOKS=1, 404 elsewhere — like its `/api/test/*` siblings): creates the users+players rows for a username if missing and mints a session, idempotent across container restarts since the CI DB volume persists. `e2e/auth.ts` mints + applies it per test (context extraHTTPHeaders for spec-side requests, localStorage init script for the app's own fetches); every guarded-route spec authenticates in a beforeEach; events.spec uses the real client path (`?session=`); api.spec/openapi tests track the new eighteenth endpoint. Admin spec reworked: unauthenticated visitors see the sign-in screen (no shell), wrong credentials error there, and logout returns to it.
- **Tests.** Unit: new `lib/auth.test.ts` (hash round-trip; 401 on missing/unknown/expired session; admin→local player join; 500 on missing players row), register route tests (validation matrix, shared-id rows + starter economy + minted session, case-insensitive 409) and mint-hook tests (gate off → 404 even with CI's FLIGHT_TEST_HOOKS=1 env — stubbed explicitly, default-username creation, idempotent re-mint, bad usernames). All existing state/events/action route tests now mint a session for the seeded admin user in beforeAll. OpenAPI: `/api/register` documented (RegisterRequest schema; 200/400/409) — spec is now eighteen paths.
## v1.0.0 — 2026-09-24 14:20
- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before.
- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas.
- **Tests.** Unit: fresh DB now asserts the seeded local player row (`local`/`local`/`kamilkrzywda`, starting cash/reputation) AND that no `account` table exists; new dedicated migration test builds a minimal legacy file containing only an `account` table and verifies open() copies its row into the local player, drops `account`, and is a no-op on second open. All existing suites (actions, engine, client, buy/sell/abandon routes) now read/write through the helper; the admin rows CRUD test's PK-less-table example moves from `account` to `sim_state` (the only remaining PK-less table), and the admin tables-list expectation swaps `account` → `players`. No new e2e spec — behavior is byte-identical and CI's existing suite covers the UI paths; a fresh-stack boot exercises the new seed path.
## v0.73.0 — 2026-09-24 12:24
- **New-package toasts removed.** The client-side snapshot diff in `lib/deriveEvents.ts` no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged.
## v0.72.0 — 2026-09-24 11:44
- **Admin login fixed.** The seeded admin's scrypt digest in `lib/db/client.ts` was computed with different parameters than the verifier (`verifyPassword()` passes the salt as a plain string to `scryptSync(pw, saltHex, 32)`), so real logins failed with 401 "invalid credentials" since v0.71.0. The seed now carries a digest produced by exactly that call (the plaintext still exists nowhere in code, tests or comments). Because the seed is `INSERT … WHERE NOT EXISTS`, pre-existing databases keep their old row — so every `open()` also runs an idempotent rotation (`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`): a no-op once healed, it repairs legacy rows at boot and is durable on the next persist. Unit tests cover the fresh seed with the new digest plus the rotate-on-open path (legacy file heals to the current digest; second open is a no-op).
## v0.71.0 — 2026-09-24 09:18
- **Admin panel.** A new "Admin" button on the right of the top bar opens a reusable modal (`components/Modal.tsx` — fixed overlay at z-index 60, above the z-50 toast stack; centered `role="dialog"` panel; closes on Escape and backdrop click) hosting `components/AdminPanel.tsx`. No new URL routes: everything lives behind that one button + modal.
- **Users + sessions tables.** New `users` (id PK, unique username, scrypt `salt$hex` password_hash, admin flag, created_at) and `sessions` (UUID id, user_id, 7-day expires_at) tables in the raw DDL (`lib/db/client.ts`) and typed Drizzle entries (`lib/db/schema.ts`). An **idempotent seeded admin** is inserted on every `open()` — user id `admin-kamilkrzywda`, username `kamilkrzywda`, admin=1, hash-only (the plaintext exists nowhere in code, tests or comments) — guarded by `WHERE NOT EXISTS` so pre-existing production DBs gain exactly one row and never a duplicate. The users table is also the foundation for multiplayer auth (#105/#107).
- **Auth API.** `POST /api/login {username,password}` verifies with node:crypto `scryptSync` + `timingSafeEqual`; an unknown username still runs one full scrypt against a fixed dummy digest so timing stays uniform. Success mints a 7-day session (lazy cleanup of expired rows) and returns `{sessionId, user:{username,admin}}`. `POST /api/logout` deletes the session named by the new `X-Flight-Session` header (idempotent); `GET /api/me` resolves it to the current user or 401. The client stores the sessionId in localStorage (`flight.session`) and sends the header on every authenticated request.
- **Generic whole-database CRUD behind the admin flag.** `lib/auth.ts`'s `requireAdmin` guard (no/expired session → 401, non-admin → 403) runs before any input is touched on: `GET /api/admin/tables` (user tables, sqlite_% excluded) and `GET|PUT|POST|DELETE /api/admin/tables/{name}/rows` — rows carry their rowid as `__rowid` so the PK-less tables (account, sim_state) are editable like any other; PUT updates only changed cells, POST inserts, DELETE removes by rowid. SQL safety: table names quoted only after sqlite_master validation, column names validated against PRAGMA table_info, values ALWAYS bound as parameters — no user input is ever concatenated into SQL.
- **UI.** The Admin modal shows a login form (wrong credentials show an error line), a "not an admin" notice for authenticated non-admins, or the grid: table list → per-table editable rows (each cell an input; per-row Save/Delete, "+ Add row", Refresh) with logout in the header area. Deliberately plain — it's a dev tool surface, not a product feature.
- **Test hook.** `POST /api/test/admin-session` (FLIGHT_TEST_HOOKS=1 gate like its siblings) mints a session for the seeded admin server-side and returns `{sessionId}` — so the e2e suite can exercise the authenticated panel WITHOUT the real admin password ever appearing in test code.
- **OpenAPI.** Five new paths documented (`/api/login`, `/api/logout`, `/api/me`, `/api/admin/tables`, `/api/admin/tables/{name}/rows` with all four verbs) plus their request/response schemas; endpoint count twelve → seventeen everywhere it is asserted (unit + e2e).
- **Tests.** Unit: the idempotent admin seed (fresh DB, reopen, and a legacy pre-v0.71.0 file), login success/wrong-password/unknown-username/malformed bodies with an in-test scrypt hash for a self-created user (the real admin's plaintext is never referenced), logout idempotency, /api/me with missing/unknown/expired/live sessions, the guard ladder (401 no header, 401 expired, 403 non-admin) and the full CRUD validation matrix (rows incl. `__rowid` for account, PUT-verified-by-refetch, POST+DELETE round-trip, unknown table 404, unknown column/bad limit/missing rowid 400). E2E (`e2e/admin.spec.ts`, CI-only): open modal → login form → wrong creds error → hook-minted session via localStorage → table list incl. packages/users → edit a cell + Save verified by an independent re-fetch → add + delete a users row → logout back to the login form.
## v0.70.0 — 2026-09-23 22:07
- **Default map view is now the whole world.** The initial camera in `components/Map.tsx` moves from the Europe overview `[10,52]` z4 to null island `[0,0]` (renamed constant `WORLD_CENTER`) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. `MIN_ZOOM` is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; `MAX_ZOOM = 11` and the flat-2D view are unchanged.
- **E2E.** The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: `e2e/map.spec.ts` gains a shared `frameLfrs` helper (`jumpTo` z5 + wait until the marker renders), used by all three of its click tests; `e2e/map-interactions.spec.ts` and `e2e/relocation.spec.ts` frame LFRS at z5 before their popup clicks. The plane-marker test in `e2e/map-interactions.spec.ts` fits EDDF+LEMD into the viewport first — `queryRenderedFeatures` is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. `e2e/hover.spec.ts`: the antimeridian test's `setMinZoom(0)` workaround is deleted — minZoom is natively 0 now, so `fitToAirports` can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in `e2e/map.spec.ts` pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0).
## v0.69.0 — 2026-09-23 21:54
- **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
- **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway.
- **Tests.** Unit: the `ignorePackage` suite is deleted; terminal-status fixtures drop their redundant `'ignored'` rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to `'expired'` mirroring `expireOpenPackages`; the taken-slot cooldown ladder keeps two stamped slots, both `'loaded'`. E2E: the Ignore UI test is deleted; `sim.spec.ts`'s soft-lock regression drains via the existing `/api/test/expire-package` hook (loading can't drain a pool — the starter's payload fits only the lightest band) and `expiry.spec.ts` cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.
## v0.68.0 — 2026-09-23 20:32
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`.
## 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 ## 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. - **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'`. - **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'`.
@@ -0,0 +1,184 @@
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 { createSession } from '@/lib/auth';
import { DELETE, GET, POST, PUT } from './route';
// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables),
// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input).
let dir: string;
let adminSession = '';
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
// adminSession is minted in beforeAll, so the header helpers read it at call time.
const authHeaders = () => ({ 'X-Flight-Session': adminSession });
// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly.
const ctx = (name: string) => ({ params: Promise.resolve({ name }) });
const getRows = (name: string, query = '') =>
GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name));
const putRow = (name: string, body: unknown) =>
PUT(
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
method: 'PUT',
headers: { ...authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
ctx(name),
);
const postRow = (name: string, body: unknown) =>
POST(
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
method: 'POST',
headers: { ...authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
ctx(name),
);
const deleteRow = (name: string, rowid: number | null) =>
DELETE(
new Request(
`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`,
{ method: 'DELETE', headers: authHeaders() },
),
ctx(name),
);
describe('admin rows CRUD', () => {
beforeAll(async () => {
adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
it('GET returns columns + rows with __rowid for the PK-less sim_state table', async () => {
const res = await getRows('sim_state');
expect(res.status).toBe(200);
const body = (await res.json()) as { columns: string[]; rows: Array<Record<string, unknown>> };
expect(body.columns[0]).toBe('__rowid');
for (const col of ['clock_ms', 'time_scale']) expect(body.columns).toContain(col);
expect(body.rows).toHaveLength(1); // the single seeded sim_state row
expect(typeof body.rows[0].__rowid).toBe('number');
expect(typeof body.rows[0].clock_ms).toBe('number');
});
it('PUT updates a cell and the change survives a re-fetch', async () => {
const before = await (await getRows('sim_state')).json();
const originalClockMs = (before as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms;
const res = await putRow('sim_state', { rowid: 1, values: { clock_ms: 424_242 } });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
// Re-fetch through the same route — the persisted value is back.
const body = await (await getRows('sim_state')).json();
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms).toBe(424_242);
// Restore the original clock so later tests see a sane world.
const restore = await putRow('sim_state', { rowid: 1, values: { clock_ms: originalClockMs } });
expect(restore.status).toBe(200);
});
it('PUT rejects unknown columns and empty values with 400', async () => {
const badCol = await putRow('sim_state', { rowid: 1, values: { no_such_column: 1 } });
expect(badCol.status).toBe(400);
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
const empty = await putRow('sim_state', { rowid: 1, values: {} });
expect(empty.status).toBe(400);
const noRowid = await putRow('sim_state', { values: { clock_ms: 1 } });
expect(noRowid.status).toBe(400);
});
it('POST inserts a row and DELETE removes it again (users table)', async () => {
const created = await postRow('users', {
values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() },
});
expect(created.status).toBe(200);
const body = (await created.json()) as { ok: boolean; rowid: number };
expect(body.ok).toBe(true);
expect(Number.isInteger(body.rowid)).toBe(true);
// Visible via GET…
let list = await (await getRows('users')).json();
const rows = (list as { rows: Array<Record<string, unknown>> }).rows;
expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true);
// …and gone after DELETE.
const del = await deleteRow('users', body.rowid);
expect(del.status).toBe(200);
list = await (await getRows('users')).json();
expect((list as { rows: Array<Record<string, unknown>> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false);
// DELETE is idempotent.
const again = await deleteRow('users', body.rowid);
expect(again.status).toBe(200);
});
it('POST rejects unknown columns and empty values with 400', async () => {
const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } });
expect(badCol.status).toBe(400);
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
const empty = await postRow('users', { values: {} });
expect(empty.status).toBe(400);
});
it('DELETE requires an integer rowid query parameter', async () => {
const missing = await deleteRow('users', null);
expect(missing.status).toBe(400);
const bad = await DELETE(
new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }),
ctx('users'),
);
expect(bad.status).toBe(400);
});
it('returns 404 for an unknown table on every verb', async () => {
const g = await getRows('no_such_table');
expect(g.status).toBe(404);
const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } });
expect(p.status).toBe(404);
const i = await postRow('no_such_table', { values: { x: 1 } });
expect(i.status).toBe(404);
const d = await deleteRow('no_such_table', 1);
expect(d.status).toBe(404);
});
it('rejects a non-integer limit with 400 and caps at 1000', async () => {
const bad = await getRows('users', '?limit=abc');
expect(bad.status).toBe(400);
const capped = await getRows('users', '?limit=999999');
expect(capped.status).toBe(200);
});
it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => {
const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users'));
expect(noHeader.status).toBe(401);
const inst = await getGameDb();
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`,
[Date.now()],
);
const nonAdminSession = await createSession(inst, 'user-rows-nonadmin');
const forbidden = await GET(
new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }),
ctx('users'),
);
expect(forbidden.status).toBe(403);
});
});
+219
View File
@@ -0,0 +1,219 @@
import { persist } from '@/lib/db/client';
import type { GameDb } from '@/lib/db/client';
import { requireAdmin } from '@/lib/auth';
// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any
// user input is touched). SQL safety: table names are quoted only after validation against
// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE
// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string.
const DEFAULT_LIMIT = 500;
const MAX_LIMIT = 1000;
function quoteIdent(name: string): string {
return '"' + name.replace(/"/g, '""') + '"';
}
// The app's own tables (sqlite_% internals excluded) — the only names ever accepted.
function tableNames(inst: GameDb): Set<string> {
const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
return new Set(res[0].values.map((r) => String(r[0])));
}
// Column names of a validated table (name already checked against sqlite_master).
function columnsOf(inst: GameDb, name: string): string[] {
const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`);
return res[0].values.map((r) => String(r[1]));
}
// Parameterized execute — the only way values reach SQL in this file.
function run(inst: GameDb, sql: string, values: unknown[]): void {
inst.raw.run(sql, values as never[]);
}
async function readBody(req: Request): Promise<Record<string, unknown> | Response> {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} 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 (!ALLOWED_BODY_FIELDS.has(key)) {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
return input;
}
// Per-method top-level body fields (strict, like the /api/test/* hooks).
const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']);
function valuesObject(input: Record<string, unknown>): Record<string, unknown> | Response {
const values = input.values;
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 });
}
return values as Record<string, unknown>;
}
// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit;
// anything else is a server error.
function sqlError(err: unknown): Response {
const msg = err instanceof Error ? err.message : String(err);
if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) {
return Response.json({ error: `database constraint: ${msg}` }, { status: 409 });
}
console.error('admin rows route failed', err);
return Response.json({ error: 'database error' }, { status: 500 });
}
// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default
// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable
// like any other. Unknown table → 404; bad limit → 400.
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
let limit = DEFAULT_LIMIT;
const limitParam = new URL(req.url).searchParams.get('limit');
if (limitParam !== null) {
limit = Number(limitParam);
if (!Number.isInteger(limit) || limit < 1) {
return Response.json({ error: 'limit must be a positive integer' }, { status: 400 });
}
limit = Math.min(limit, MAX_LIMIT);
}
const cols = columnsOf(guard.inst, name);
const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`);
try {
stmt.bind([limit]);
const rows: Array<Record<string, unknown>> = [];
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
return Response.json({ columns: ['__rowid', ...cols], rows });
} catch (err) {
return sqlError(err);
} finally {
stmt.free();
}
}
// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the
// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty
// values → 400.
export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const input = await readBody(req);
if (input instanceof Response) return input;
const rowid = input.rowid;
if (!Number.isInteger(rowid)) {
return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 });
}
const values = await valuesObject(input);
if (values instanceof Response) return values;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const cols = columnsOf(guard.inst, name);
for (const col of Object.keys(values)) {
if (!cols.includes(col)) {
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
}
}
if (Object.keys(values).length === 0) {
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
}
const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', ');
try {
run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [
...Object.values(values),
rowid,
]);
await persist(guard.inst);
return Response.json({ ok: true });
} catch (err) {
return sqlError(err);
}
}
// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with
// exactly the given columns (others take their defaults or NULL). Returns the new rowid.
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const input = await readBody(req);
if (input instanceof Response) return input;
const values = await valuesObject(input);
if (values instanceof Response) return values;
if (Object.keys(values).length === 0) {
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
}
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const cols = columnsOf(guard.inst, name);
for (const col of Object.keys(values)) {
if (!cols.includes(col)) {
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
}
}
const keys = Object.keys(values);
try {
run(
guard.inst,
`INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
Object.values(values),
);
// Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets
// last_insert_rowid to 0 on this connection.
const res = guard.inst.raw.exec('SELECT last_insert_rowid()');
await persist(guard.inst);
return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) });
} catch (err) {
return sqlError(err);
}
}
// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404;
// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent).
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const rowidParam = new URL(req.url).searchParams.get('rowid');
const rowid = Number(rowidParam);
if (rowidParam === null || !Number.isInteger(rowid)) {
return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 });
}
try {
run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]);
await persist(guard.inst);
return Response.json({ ok: true });
} catch (err) {
return sqlError(err);
}
}
+80
View File
@@ -0,0 +1,80 @@
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 { createSession } from '@/lib/auth';
import { GET } from './route';
// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403,
// admin passes and sees every user table (sqlite_% excluded).
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const get = (sessionId?: string) =>
GET(
new Request('http://localhost/api/admin/tables', {
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
async function makeUser(username: string, admin: number): Promise<string> {
const inst = await getGameDb();
const id = `user-${username}`;
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`,
[id, username, admin, Date.now(), id],
);
return id;
}
describe('GET /api/admin/tables', () => {
it('returns 401 without a session header', async () => {
const res = await get();
expect(res.status).toBe(401);
});
it('returns 401 for an expired session', async () => {
const inst = await getGameDb();
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
'expired-admin-session',
'admin-kamilkrzywda',
Date.now() - 1000,
]);
const res = await get('expired-admin-session');
expect(res.status).toBe(401);
});
it('returns 403 for a valid non-admin session', async () => {
const userId = await makeUser('tables-nonadmin', 0);
const sessionId = await createSession(await getGameDb(), userId);
const res = await get(sessionId);
expect(res.status).toBe(403);
expect(((await res.json()) as { error: string }).error).toMatch(/admin/i);
});
it('lists every user table for an admin, excluding sqlite_% internals', async () => {
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
const res = await get(sessionId);
expect(res.status).toBe(200);
const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name);
for (const expected of ['packages', 'players', 'planes', 'sessions', 'sim_state', 'users']) {
expect(names, `missing ${expected}`).toContain(expected);
}
expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false);
// Name-ordered.
expect([...names].sort()).toEqual(names);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { requireAdmin } from '@/lib/auth';
// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name
// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker.
export async function GET(req: Request) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const res = guard.inst.raw.exec(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
);
return Response.json(res[0].values.map((r) => ({ name: String(r[0]) })));
}
+74 -1
View File
@@ -2,9 +2,15 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { getPlayer } from '@/lib/db/localPlayer';
import { tickNow } from '@/lib/sim/engine';
import { GET } from './route'; import { GET } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so // Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
@@ -12,6 +18,8 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton). // vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -20,8 +28,18 @@ afterAll(async () => {
}); });
describe('GET /api/events', () => { describe('GET /api/events', () => {
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
// param — this test exercises exactly that path (requireAuthSse).
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
it('rejects an unauthenticated request with 401', async () => {
const res = await GET(new Request('http://localhost/api/events'));
expect(res.status).toBe(401);
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
});
it('streams SSE with the current snapshot as the first event', async () => { it('streams SSE with the current snapshot as the first event', async () => {
const res = await GET(); const res = await GET(req());
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache'); expect(res.headers.get('cache-control')).toBe('no-cache');
@@ -34,8 +52,63 @@ describe('GET /api/events', () => {
expect(text.startsWith('data: ')).toBe(true); expect(text.startsWith('data: ')).toBe(true);
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>; const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number'); expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
const planes = snap.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['ownerId', 'ownerName']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key);
}
const pkgs = snap.packages as Array<Record<string, unknown>>;
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
} finally { } finally {
await reader.cancel(); // → stream cancel → unsubscribe from the registry await reader.cancel(); // → stream cancel → unsubscribe from the registry
} }
}); });
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// A second player with a distinct economy + their own session (rows minted like /api/register).
const idB = 'test-player-events-b';
await inst.db.insert(schema.users).values({
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
});
const sidB = await createSession(inst, idB);
const localCash = (await getPlayer(inst, 'local')).cash;
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
// Reads the next `data:` event off a stream and parses its snapshot.
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
const { value } = await reader.read();
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
};
try {
// Drain both connect frames (each already carries its own account row)…
const connectA = await readFrame(readerA);
const connectB = await readFrame(readerB);
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
await tickNow();
const pushA = await readFrame(readerA);
const pushB = await readFrame(readerB);
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
} finally {
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
await readerB.cancel();
}
});
}); });
+18 -5
View File
@@ -1,3 +1,4 @@
import { requireAuthSse } from '@/lib/auth';
import { getStateSnapshot, sseFrame } from '@/lib/game/state'; import { getStateSnapshot, sseFrame } from '@/lib/game/state';
import { subscribe } from '@/lib/sse'; import { subscribe } from '@/lib/sse';
@@ -5,12 +6,22 @@ import { subscribe } from '@/lib/sse';
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions // 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): // 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 // frames carry the sseFrame projection (terminal package history dropped) — same shape as the
// pushed frames, so a connect never costs one full-history frame. // pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
// be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
// player, pushing each frame only to its group; connect + pushed frames share one projection
// (getStateSnapshot + sseFrame), so they can never drift.
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000; const HEARTBEAT_MS = 15_000;
export async function GET() { export async function GET(req: Request) {
const auth = await requireAuthSse(req);
if (!auth.ok) return auth.response;
let heartbeat: ReturnType<typeof setInterval> | null = null; let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
const cleanup = () => { const cleanup = () => {
@@ -22,10 +33,12 @@ export async function GET() {
start(controller) { start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so // 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 // 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. // for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
getStateSnapshot() // every later push carry THIS session's player projection — subscribe keys on auth.player.id so
// the tick broadcast only sends this client frames projected for its own account row.
getStateSnapshot(auth.player.id)
.then((snapshot) => { .then((snapshot) => {
unsubscribe = subscribe((text) => { unsubscribe = subscribe(auth.player.id, (text) => {
try { try {
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns // 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). // wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
+93
View File
@@ -0,0 +1,93 @@
import { randomUUID, scryptSync } from 'node:crypto';
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 { SESSION_TTL_MS } from '@/lib/auth';
import { POST } from './route';
// v0.71.0 — login route tests. The test user is created HERE with a hash computed in-test via
// scryptSync; the seeded admin's plaintext password must never appear in any test code, so it
// is deliberately not exercised here (the e2e suite gets an admin session via /api/test/admin-session).
let dir: string;
const TEST_USER = 'unit-test-user';
const TEST_PASSWORD = 'right-horse-wrong-street-42';
const SALT_HEX = randomUUID().replace(/-/g, '').slice(0, 32); // 16-byte hex salt
const HASH = `${SALT_HEX}$${scryptSync(TEST_PASSWORD, SALT_HEX, 32).toString('hex')}`;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-login-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'login.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/login', { method: 'POST', body: JSON.stringify(body) }));
async function createTestUser(): Promise<void> {
const inst = await getGameDb();
// Idempotent — two tests in this file both want the user.
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-unit-test', ?, ?, 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = ?)`,
[TEST_USER, HASH, Date.now(), TEST_USER],
);
}
describe('POST /api/login', () => {
it('rejects malformed bodies with 400', async () => {
for (const body of [{}, { username: 'x' }, { password: 'y' }, { username: '', password: 'p' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('returns 401 for an unknown username (and still runs a scrypt)', async () => {
const res = await post({ username: 'no-such-user', password: TEST_PASSWORD });
expect(res.status).toBe(401);
expect(((await res.json()) as { error: string }).error).toMatch(/invalid credentials/i);
});
it('returns 401 for a wrong password and mints no session', async () => {
await createTestUser();
const before = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0];
const res = await post({ username: TEST_USER, password: 'definitely-wrong' });
expect(res.status).toBe(401);
const after = Number((await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]);
expect(after).toBe(Number(before));
});
it('mints a ~7-day session on success and returns the user', async () => {
await createTestUser();
const t0 = Date.now();
const res = await post({ username: TEST_USER, password: TEST_PASSWORD });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
expect(body.user).toEqual({ username: TEST_USER, admin: false });
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const row = (await getGameDb()).raw.exec('SELECT user_id, expires_at FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(row[0])).toBe('user-unit-test');
const ttl = Number(row[1]) - t0;
expect(ttl).toBeGreaterThan(SESSION_TTL_MS - 5_000); // ~7 days, minus test drift
expect(ttl).toBeLessThan(SESSION_TTL_MS + 5_000);
});
it('login works for the seeded admin row too (hash verified without its plaintext)', async () => {
// The seeded admin's hash is a fixed string in client.ts; we can only prove the route
// ACCEPTS exactly that stored digest by checking verification against a known pair — which
// would require the plaintext. So instead: assert the seeded row is what login looks up, by
// confirming an unknown-password attempt for 'kamilkrzywda' yields 401 (row found, hash mismatch).
const res = await post({ username: 'kamilkrzywda', password: 'wrong-on-purpose' });
expect(res.status).toBe(401);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { getGameDb } from '@/lib/db/client';
import { createSession, verifyPassword, DUMMY_HASH_FOR_UNKNOWN_USER } from '@/lib/auth';
// v0.71.0 — POST /api/login
// body { username: string, password: string } (both required non-empty; unknown fields 400).
// Success → 200 { sessionId, user: { username, admin } }; the session row lives 7 days and is
// sent back as X-Flight-Session on later requests. Failure → 401 { error }. An unknown
// username still runs one full scrypt against a fixed dummy digest so timing stays uniform.
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} 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 !== 'username' && key !== 'password') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = input.username;
const password = input.password;
if (typeof username !== 'string' || username.length === 0) {
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
}
if (typeof password !== 'string' || password.length === 0) {
return Response.json({ error: 'password is required and must be a non-empty string' }, { status: 400 });
}
const inst = await getGameDb();
const res = inst.raw.exec('SELECT id, password_hash, admin FROM users WHERE username = ?', [username]);
const row = res[0]?.values?.[0];
if (!row) {
verifyPassword(password, DUMMY_HASH_FOR_UNKNOWN_USER); // uniform timing — result discarded
return Response.json({ error: 'invalid credentials' }, { status: 401 });
}
if (!verifyPassword(password, String(row[1]))) {
return Response.json({ error: 'invalid credentials' }, { status: 401 });
}
const sessionId = await createSession(inst, String(row[0]));
return Response.json({
sessionId,
user: { username, admin: Number(row[2]) === 1 },
});
}
+60
View File
@@ -0,0 +1,60 @@
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 { createSession } from '@/lib/auth';
import { POST } from './route';
// v0.71.0 — logout route tests: deletes the session named by X-Flight-Session, idempotently.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-logout-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'logout.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (sessionId?: string) =>
POST(
new Request('http://localhost/api/logout', {
method: 'POST',
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
async function mintSession(): Promise<string> {
const inst = await getGameDb();
return createSession(inst, 'admin-kamilkrzywda'); // any user id works — logout only deletes the session row
}
describe('POST /api/logout', () => {
it('returns 401 without a session header', async () => {
const res = await post();
expect(res.status).toBe(401);
});
it('deletes the named session and is idempotent on repeat calls', async () => {
const sessionId = await mintSession();
const count = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
expect(Number(count)).toBe(1);
const first = await post(sessionId);
expect(first.status).toBe(200);
expect(await first.json()).toEqual({ ok: true });
// The row is gone…
const after = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
expect(Number(after)).toBe(0);
// …and a second logout of the same (now unknown) session is still 200.
const second = await post(sessionId);
expect(second.status).toBe(200);
expect(await second.json()).toEqual({ ok: true });
});
});
+16
View File
@@ -0,0 +1,16 @@
import { getGameDb, persist } from '@/lib/db/client';
import { SESSION_HEADER } from '@/lib/auth';
// v0.71.0 — POST /api/logout (no body). Deletes the session named by the X-Flight-Session
// header; idempotent — deleting an unknown or already-deleted session is still 200. A missing
// header has nothing to delete and gets 401 like every other unauthenticated request here.
export async function POST(req: Request) {
const sessionId = req.headers.get(SESSION_HEADER);
if (!sessionId) return Response.json({ error: 'authentication required' }, { status: 401 });
const inst = await getGameDb();
inst.raw.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
await persist(inst);
return Response.json({ ok: true });
}
+17 -7
View File
@@ -2,18 +2,22 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // 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). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -21,7 +25,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) })); const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/buy-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/buy-plane', () => { describe('POST /api/market/buy-plane', () => {
it('rejects a missing model, unknown fields and unknown models with 400', async () => { it('rejects a missing model, unknown fields and unknown models with 400', async () => {
@@ -38,14 +49,13 @@ describe('POST /api/market/buy-plane', () => {
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
// Cash untouched by the failed attempt. // Cash untouched by the failed attempt.
const account = (await (await getGameDb()).db.select().from(schema.account))[0]; expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000);
expect(account.cash).toBe(20_000);
}); });
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => { it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
const { db } = await getGameDb(); const inst = await getGameDb();
// Single-row account table — top up cash so the purchase can succeed. // Local player row — top up cash so the purchase can succeed.
await db.update(schema.account).set({ cash: 500_000 }); await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const res = await post({ model: 'atr72' }); const res = await post({ model: 'atr72' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
} }
const model = input.model; const model = input.model;
return performAction((inst) => buyPlane(inst, model)); return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
} }
+17 -5
View File
@@ -4,17 +4,22 @@ import path from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // 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). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,7 +27,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) })); const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/sell-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/sell-plane', () => { describe('POST /api/market/sell-plane', () => {
it('rejects a missing planeId and unknown fields with 400', async () => { it('rejects a missing planeId and unknown fields with 400', async () => {
@@ -48,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
// A second, grounded plane so "flying" is the reason for rejection. // A second, grounded plane so "flying" is the reason for rejection.
await db.insert(schema.planes).values({ await db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
}); });
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra')); await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
@@ -59,10 +71,10 @@ describe('POST /api/market/sell-plane', () => {
}); });
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => { it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
const { db } = await getGameDb(); const inst = await getGameDb();
// The previous test left 'plane-extra' in the DB (now flying) — ground it. // The previous test left 'plane-extra' in the DB (now flying) — ground it.
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra')); await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
const cashBefore = (await db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post({ planeId: 'plane-extra' }); const res = await post({ planeId: 'plane-extra' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
} }
const planeId = input.planeId; const planeId = input.planeId;
return performAction((inst) => sellPlane(inst, planeId)); return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
} }
+72
View File
@@ -0,0 +1,72 @@
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 { createSession } from '@/lib/auth';
import { GET } from './route';
// v0.71.0 — /api/me tests: resolves X-Flight-Session to its user, 401 otherwise.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-me-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'me.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const get = (sessionId?: string) =>
GET(
new Request('http://localhost/api/me', {
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
describe('GET /api/me', () => {
it('returns 401 without a session header', async () => {
const res = await get();
expect(res.status).toBe(401);
});
it('returns 401 for an unknown session id', async () => {
const res = await get('00000000-0000-4000-8000-000000000000');
expect(res.status).toBe(401);
});
it('returns 401 for an expired session', async () => {
const inst = await getGameDb();
// A session whose expiry is already in the past.
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
'expired-session-id',
'admin-kamilkrzywda',
Date.now() - 1000,
]);
const res = await get('expired-session-id');
expect(res.status).toBe(401);
});
it('returns the user for a live session (admin flag included)', async () => {
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ username: 'kamilkrzywda', admin: true });
});
it('reports non-admin users as such', async () => {
const inst = await getGameDb();
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-me-test', 'me-test-user', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-me-test')`,
[Date.now()],
);
const sessionId = await createSession(inst, 'user-me-test');
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ username: 'me-test-user', admin: false });
});
});
+11
View File
@@ -0,0 +1,11 @@
import { currentUser } from '@/lib/auth';
// v0.71.0 — GET /api/me (no body). Returns the user behind a valid X-Flight-Session header as
// { username, admin }, or 401 when the header is missing/unknown/expired. The client uses it to
// decide what the Admin modal shows on open.
export async function GET(req: Request) {
const user = await currentUser(req);
if (!user) return Response.json({ error: 'authentication required' }, { status: 401 });
return Response.json({ username: user.username, admin: user.admin });
}
+15 -6
View File
@@ -4,16 +4,21 @@ import path from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // 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). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -21,9 +26,11 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), { const post = (id: string) =>
params: Promise.resolve({ id }), POST(
}); new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
{ params: Promise.resolve({ id }) },
);
// Insert a LOADED package bound to the starter plane with a long deadline. Returns // Insert a LOADED package bound to the starter plane with a long deadline. Returns
// the exact created/deadline times so tests can compute the penalty from the // the exact created/deadline times so tests can compute the penalty from the
@@ -57,7 +64,7 @@ describe('POST /api/packages/:id/abandon', () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
await db.insert(schema.packages).values({ await db.insert(schema.packages).values({
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, 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'); const res = await post('p-open');
@@ -84,8 +91,9 @@ describe('POST /api/packages/:id/abandon', () => {
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => { it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
const { db } = await getGameDb(); const inst = await getGameDb();
const cashBefore = (await db.select().from(schema.account))[0].cash; const { db } = inst;
const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post('p-ab'); const res = await post('p-ab');
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -103,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
+2 -2
View File
@@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
// id; 409 wrong status or the plane is in flight. On success returns the full // id; 409 wrong status or the plane is in flight. On success returns the full
// state snapshot. // state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params; const { id } = await ctx.params;
return performAction((inst) => abandonPackage(inst, id)); return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
} }
-10
View File
@@ -1,10 +0,0 @@
import { ignorePackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => ignorePackage(inst, id));
}
+16 -6
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // 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). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
}); });
const post = (id: string, body: unknown) => const post = (id: string, body: unknown) =>
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), { POST(
params: Promise.resolve({ id }), new Request(`http://localhost/api/packages/${id}/load`, {
}); method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id }) },
);
describe('POST /api/packages/:id/load', () => { describe('POST /api/packages/:id/load', () => {
it('rejects a missing/non-string planeId and unknown fields with 400', async () => { it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
@@ -45,7 +54,7 @@ describe('POST /api/packages/:id/load', () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
await db.insert(schema.packages).values({ await db.insert(schema.packages).values({
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, 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' }); const res = await post('p-ghost-plane', { planeId: 'ghost' });
@@ -57,7 +66,7 @@ describe('POST /api/packages/:id/load', () => {
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there. // The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
await db.insert(schema.packages).values({ await db.insert(schema.packages).values({
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1, 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' }); const res = await post('p-elsewhere', { planeId: 'plane-starter' });
@@ -82,7 +91,7 @@ describe('POST /api/packages/:id/load', () => {
// A known open package originating where the starter actually sits (EDDF). // A known open package originating where the starter actually sits (EDDF).
await db.insert(schema.packages).values({ await db.insert(schema.packages).values({
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4, 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' }); const res = await post('p-home', { planeId: 'plane-starter' });
@@ -102,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
} }
const planeId = input.planeId; const planeId = input.planeId;
return performAction((inst) => loadPackage(inst, id, planeId)); return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
} }
+13 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // 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). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
}); });
const post = (planeId: string, body: unknown) => const post = (planeId: string, body: unknown) =>
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), { POST(
params: Promise.resolve({ planeId }), new Request(`http://localhost/api/planes/${planeId}/fly`, {
}); method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ planeId }) },
);
describe('POST /api/planes/:planeId/fly', () => { describe('POST /api/planes/:planeId/fly', () => {
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => { it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
@@ -93,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const destIcao = input.destIcao; const destIcao = input.destIcao;
return performAction((inst) => flyPlane(inst, planeId, destIcao)); return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
} }
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const index = input.index as number; const index = input.index as number;
return performAction((inst) => cancelQueuedLeg(inst, planeId, index)); return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
} }
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const destIcao = input.destIcao; const destIcao = input.destIcao;
return performAction((inst) => queueLeg(inst, planeId, destIcao)); return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
} }
+2 -2
View File
@@ -6,7 +6,7 @@ import { performAction, repairPlane } from '@/lib/game/actions';
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. // repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
// On success returns the full state snapshot. // On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params; const { planeId } = await ctx.params;
return performAction((inst) => repairPlane(inst, planeId)); return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
} }
+75
View File
@@ -0,0 +1,75 @@
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 { verifyPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { POST } from './route';
// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB
// singleton points at a throwaway file, and every created user is verified through verifyPassword().
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'register.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/register', { method: 'POST', body: JSON.stringify(body) }));
describe('POST /api/register', () => {
it('rejects malformed bodies and bad credentials with 400', async () => {
for (const body of [
{}, // missing both fields
{ username: 'ab', password: 'longenough' }, // too short
{ username: 'a'.repeat(21), password: 'longenough' }, // too long
{ username: 'bad user!', password: 'longenough' }, // bad charset (space + !)
{ username: 'validuser', password: 'short' }, // password < 8 chars
{ username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field
]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => {
const res = await post({ username: 'NewUser42', password: 'correct-horse-1' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0];
const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true);
expect(String(playerRow[1])).toBe('local');
expect(Number(playerRow[2])).toBe(STARTING_CASH);
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('returns 409 when the username is taken (case-insensitively)', async () => {
await post({ username: 'taken-user', password: 'correct-horse-1' });
const res = await post({ username: 'Taken-User', password: 'different-pass-2' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// v1.1.0 (ISS-107) — POST /api/register
// body { username: string, password: string } (both required; unknown fields 400).
// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400.
// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same
// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact
// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a
// session immediately and returns { sessionId, user } like /api/login — no second round-trip.
const USERNAME_RE = /^[a-z0-9_-]{3,20}$/;
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} 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 !== 'username' && key !== 'password') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = typeof input.username === 'string' ? input.username.toLowerCase() : '';
if (!USERNAME_RE.test(username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
const password = input.password;
if (typeof password !== 'string' || password.length < 8) {
return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 });
}
const inst = await getGameDb();
const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]);
if (taken[0]?.values?.length) {
return Response.json({ error: 'username is already taken' }, { status: 409 });
}
// One UUID for both rows — the account and its player share an id; requireAuth still joins on username.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(password),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
await persist(inst);
const sessionId = await createSession(inst, id);
return Response.json({ sessionId, user: { username, admin: false } });
}
+87 -8
View File
@@ -2,12 +2,17 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route'; import { GET } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never // Point the app's DB singleton at a throwaway file so this test never
@@ -15,8 +20,14 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton). // vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
// (no password needed: createSession only inserts into sessions; the guard joins players on
// username → the 'local' row).
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
afterAll(async () => { afterAll(async () => {
delete process.env.FLIGHT_DB_PATH; delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
@@ -24,7 +35,7 @@ afterAll(async () => {
describe('GET /api/state', () => { describe('GET /api/state', () => {
it('returns 200 with the full snapshot shape', async () => { it('returns 200 with the full snapshot shape', async () => {
const res = await GET(); const res = await GET(req());
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json'); expect(res.headers.get('content-type')).toContain('application/json');
@@ -35,6 +46,7 @@ describe('GET /api/state', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
@@ -48,8 +60,8 @@ describe('GET /api/state', () => {
const planes = body.planes as Array<Record<string, unknown>>; const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1); expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) { for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key); expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
} }
expect(typeof planes[0].locationName).toBe('string'); expect(typeof planes[0].locationName).toBe('string');
// The seeded starter is grounded — no flight destination yet. // The seeded starter is grounded — no flight destination yet.
@@ -58,8 +70,8 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>; const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0); expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
expect(packages[0], `package.${key}`).toHaveProperty(key); expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
} }
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the // v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
@@ -75,13 +87,13 @@ describe('GET /api/state', () => {
}); });
it('is stable across repeated calls (same seeded DB)', async () => { it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>; const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>; const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash); expect((await b).account.cash).toBe((await a).account.cash);
}); });
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => { it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{ const body = ((await GET(req())).json()) as Promise<{
catalog: Array<Record<string, unknown>>; catalog: Array<Record<string, unknown>>;
fuelPricePerL: number; fuelPricePerL: number;
sellFraction: number; sellFraction: number;
@@ -104,4 +116,71 @@ describe('GET /api/state', () => {
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L); expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION); expect(snap.sellFraction).toBe(SELL_FRACTION);
}); });
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy — rows minted like /api/register does.
const id = 'test-player-b';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
});
const sid = await createSession(inst, id);
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
expect(res.status).toBe(200);
const body = (await res.json()) as { account: { cash: number; reputation: number } };
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
});
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// A second player with a plane of their own — rows minted like /api/register does.
const id = 'test-player-c';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
});
const type = PLANE_TYPES.atr72;
await inst.db.insert(schema.planes).values({
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
});
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
expect(res.status).toBe(200);
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
const starter = body.planes.find((p) => p.id === 'plane-starter');
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
expect(foreign?.ownerId).toBe(id);
expect(foreign?.ownerName).toBe('e2e-owner-c');
});
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
const snapBefore = await before;
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId: 'plane-test-c' })
.where(eq(schema.packages.id, String(openPkg.id)));
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
const after = await snap;
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
for (const pkg of after.packages) { // …and every unassigned package stays null.
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
}
});
}); });
+9 -2
View File
@@ -1,12 +1,19 @@
import { requireAuth } from '@/lib/auth';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
export async function GET() { // v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
// v1.4.0 (ISS-112): full shared-world snapshot — ALL planes with owner info, packages with
// carrierName; same projection as the SSE frames, so poll and push can never drift.
export async function GET(req: Request) {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try { try {
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every // Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
// read reflects current game time (expiry/finalize/spawn run inside the tick). // read reflects current game time (expiry/finalize/spawn run inside the tick).
await tickNow(); await tickNow();
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) { } catch (err) {
console.error('failed to load game state', err); console.error('failed to load game state', err);
return Response.json({ error: 'failed to load game state' }, { status: 500 }); return Response.json({ error: 'failed to load game state' }, { status: 500 });
+6 -5
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -46,11 +46,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0]; // v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
if (!account) throw new Error('account row missing'); // player the e2e suite plays via mint-player-session's default).
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 }); const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+23
View File
@@ -0,0 +1,23 @@
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
// TEST-ONLY hook (v0.71.0) — mints a live session for the SEEDED admin user server-side and
// returns its id, so the e2e suite can exercise the authenticated Admin panel WITHOUT the real
// admin password ever appearing in test code (the spec forbids it). No body. Disabled unless
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.ci.yml for the CI stack only); every other
// deployment 404s here, like its /api/test/* siblings.
export async function POST() {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
const inst = await getGameDb();
const res = inst.raw.exec("SELECT id FROM users WHERE username = 'kamilkrzywda'");
const userId = res[0]?.values?.[0]?.[0];
if (typeof userId !== 'string') {
return Response.json({ error: 'seeded admin user missing' }, { status: 500 });
}
const sessionId = await createSession(inst, userId);
return Response.json({ sessionId });
}
+5 -5
View File
@@ -1,5 +1,6 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { getSharedWorldReputation } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed'; import { generatePackage, mulberry32 } from '@/lib/game/seed';
@@ -82,10 +83,9 @@ export async function POST(req: Request) {
} }
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality // v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
// as engine spawns; never force a fixed value here. // rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
const account = (await inst.db.select().from(schema.account))[0]; const reputation = await getSharedWorldReputation(inst);
if (!account) throw new Error('account row missing');
const created = generatePackage({ const created = generatePackage({
id: randomUUID(), id: randomUUID(),
airports, airports,
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
weightKg: input.weightKg as number | undefined, weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null), special: input.special === undefined ? null : (input.special as string | null),
reputation: account.reputation, reputation,
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+69
View File
@@ -0,0 +1,69 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
// same gate, same conventions.
//
// POST /api/test/delete-plane
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
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 !== 'username') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = input.username;
if (typeof username !== 'string' || username.length === 0) {
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
let deleted = 0;
if (players[0]) {
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
for (const plane of owned) {
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
deleted++;
}
}
if (deleted > 0) {
await persist(inst);
}
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+24 -15
View File
@@ -3,21 +3,22 @@ import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; 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'; import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite // TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0; reworked for wall-clock expiry in v0.65.0) —
// observe package expiry without waiting hours of game time. Disabled unless // 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 // FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance only; every other
// only; every other deployment 404s here. // deployment 404s here.
// //
// POST /api/test/expire-package // POST /api/test/expire-package
// body { packageId?: string } // body { packageId?: string }
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS // - with packageId: ages that OPEN package's createdRealMs to HOOK_AGE_MS in the past so the next
// (game time) so the next fast tick expires it; 404 if unknown/not open. // tick (run immediately via tickNow) flips it to 'expired'; 404 if unknown/not open.
// - without: creates a fresh open package with the same short deadline. // - 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) { export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') { if (process.env.FLIGHT_TEST_HOOKS !== '1') {
@@ -41,8 +42,6 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); 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') { if (typeof packageId === 'string') {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); 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') { if (!row || row.status !== 'open') {
return Response.json({ error: 'package not found or not open' }, { status: 404 }); 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); 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]; const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
return Response.json(updated ?? row); return Response.json(updated ?? row);
} }
@@ -64,11 +66,18 @@ export async function POST(req: Request) {
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// special: null — the hook never rolls a special status (v0.37.0). // nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null }); // 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 inst.db.insert(schema.packages).values(created);
await persist(inst); 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) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
@@ -0,0 +1,95 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { POST } from './route';
// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set
// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db');
});
afterAll(async () => {
vi.unstubAllEnvs();
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body?: unknown) =>
POST(
new Request('http://localhost/api/test/mint-player-session', {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
}),
);
describe('POST /api/test/mint-player-session', () => {
it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '');
const res = await post();
expect(res.status).toBe(404);
});
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
// The session resolves to the EXISTING admin user — no new users/players rows are created.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe('admin-kamilkrzywda');
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
const inst = await getGameDb();
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
expect(userRow.length).toBe(1);
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
});
it('rejects bad usernames and unknown fields with 400', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
// /api/test/admin-session). Creates the users + players rows if missing — idempotent across
// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every
// other deployment 404s here, like its /api/test/* siblings.
//
// POST /api/test/mint-player-session
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
// with the seeded world's planes by id, so the minted session must resolve to the local player
// that owns them after the owner_id backfill); a custom username creates a fresh test account
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
// Returns { sessionId }.
const DEFAULT_USERNAME = 'kamilkrzywda';
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
// server-side); a fixed value keeps the row debuggable from the admin panel.
const TEST_PASSWORD = 'e2e-mint-password-42';
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 !== 'username') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let username = DEFAULT_USERNAME;
if (input.username !== undefined) {
if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
username = input.username;
}
const inst = await getGameDb();
let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]);
let userId = res[0]?.values?.[0]?.[0];
if (typeof userId !== 'string') {
// New test account — one UUID for both rows, starter economy like a fresh world.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(TEST_PASSWORD),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
userId = id;
}
const sessionId = await createSession(inst, userId); // persists the rows + session together
return Response.json({ sessionId });
}
+6 -5
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -42,11 +42,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0]; // v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
if (!account) throw new Error('account row missing'); // the e2e suite plays via mint-player-session's default).
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 }); await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -57,7 +58,8 @@ export async function POST(req: Request) {
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -74,7 +75,8 @@ export async function POST(req: Request) {
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs); if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+245
View File
@@ -244,6 +244,15 @@ body {
color: #bae6fd; color: #bae6fd;
} }
/* v1.4.0 (ISS-112): owner / carrier badge — whose plane or cargo this is in the shared world. */
.owner-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #bbf7d0;
}
.fuel-bar { .fuel-bar {
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
@@ -588,3 +597,239 @@ body {
font-weight: 600; font-weight: 600;
color: #f1f5f9; color: #f1f5f9;
} }
/* v0.71.0 — Admin modal: overlay sits above the z-50 toast stack; panel matches the dark slate theme. */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 60; /* above toasts (z-50) and everything else on screen */
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.7);
}
.modal-panel {
width: min(920px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, 0.97);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 8px;
box-shadow: 0 12px 40px rgba(2, 6, 23, 0.6);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
}
.modal-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
/* v0.71.0 — Admin panel content (login form + generic DB grid). */
.admin-panel {
padding: 14px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-login {
max-width: 320px;
margin: 0 auto;
width: 100%;
}
.admin-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #cbd5e1;
}
.admin-panel input[type='text'],
.admin-panel input:not([type]),
.admin-login input {
font-size: 13px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: #1e293b;
color: #e2e8f0;
}
.admin-message {
margin: 0;
font-size: 13px;
color: #cbd5e1;
}
.admin-error {
font-size: 12px;
color: #fca5a5;
}
.admin-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.admin-table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-rows-wrap {
overflow-x: auto;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 6px;
}
.admin-rows {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.admin-rows th,
.admin-rows td {
padding: 4px 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.admin-rows th {
color: #94a3b8;
font-weight: 600;
}
.admin-cell {
width: 110px;
font-size: 12px;
padding: 2px 5px;
border-radius: 3px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #1e293b;
color: #e2e8f0;
}
.admin-cell:disabled {
opacity: 0.55;
}
.admin-row-actions {
white-space: nowrap;
}
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
.leaderboard {
padding: 14px;
overflow-y: auto;
}
.leaderboard table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.leaderboard th,
.leaderboard td {
padding: 4px 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.leaderboard th {
color: #94a3b8;
font-weight: 600;
}
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
.sign-in {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: rgba(15, 23, 42, 0.97);
color: #f8fafc;
}
.sign-in h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0.06em;
}
.sign-in form {
display: flex;
flex-direction: column;
gap: 6px;
width: 300px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
background: rgba(30, 41, 59, 0.6);
}
.sign-in label {
font-size: 12px;
color: #cbd5e1;
}
.sign-in input {
font-size: 13px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(15, 23, 42, 0.9);
color: #f8fafc;
}
.sign-in .pane-btn {
margin-top: 6px;
padding: 5px 10px;
}
.sign-in-divider {
margin: 0;
font-size: 12px;
color: #94a3b8;
}
.sign-in-error {
margin: 0;
font-size: 13px;
color: #fca5a5;
}
.sign-in-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
color: #cbd5e1;
}
+55 -1
View File
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane'; import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane'; import RightPane from '@/components/RightPane';
import SignInScreen from '@/components/SignInScreen';
import ToastStack from '@/components/Toasts'; import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes'; import type { HoverTarget } from '@/components/hoverTypes';
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
import { useGameState } from '@/lib/useGameState'; import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts'; import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents'; import { diffSnapshots } from '@/lib/deriveEvents';
@@ -13,6 +15,58 @@ import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false }); const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() { export default function Home() {
// v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
// feed never polls an unauthenticated /api/state.
const [authed, setAuthed] = useState<boolean | null>(null);
// v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map.
const [isAdmin, setIsAdmin] = useState(false);
// Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must
// track whoever just signed in, not the user probed on mount (or before the last logout).
const [probeKey, setProbeKey] = useState(0);
useEffect(() => {
const sid = storedSession();
if (!sid) {
setAuthed(false);
return;
}
let cancelled = false;
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
.then(async (res) => {
if (cancelled) return;
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158)
setAuthed(res.ok);
})
.catch(() => {
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
});
return () => {
cancelled = true;
};
}, [probeKey]);
if (authed === null) {
return (
<div className="app-shell">
<p data-testid="auth-checking" className="sign-in-loading">
Loading…
</p>
</div>
);
}
if (!authed) {
// v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed —
// isAdmin has to come from the user who just signed in.
return <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
}
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
return <AppShell onLogout={() => setAuthed(false)} isAdmin={isAdmin} />;
}
function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // 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) // 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. // lets a pane push an action endpoint's full response snapshot in immediately.
@@ -49,7 +103,7 @@ export default function Home() {
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; {/* 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 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 */} action response into the shared state, exactly like RightPane does */}
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} /> <Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} /> <RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<ToastStack toasts={toasts} onDismiss={dismiss} /> <ToastStack toasts={toasts} onDismiss={dismiss} />
</div> </div>
+403
View File
@@ -0,0 +1,403 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
interface Me {
username: string;
admin: boolean;
}
interface RowsData {
columns: string[];
rows: Array<Record<string, unknown>>;
}
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
const [session, setSession] = useState<string | null>(null);
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
const [checking, setChecking] = useState(true);
// Login form state.
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState<string | null>(null);
// Admin grid state.
const [tables, setTables] = useState<string[]>([]);
const [table, setTable] = useState<string | null>(null);
const [rowsData, setRowsData] = useState<RowsData | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
const [newRow, setNewRow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
// On mount: a stored session decides between the grid and the login form.
useEffect(() => {
let cancelled = false;
const stored = storedSession();
if (!stored) {
setChecking(false);
return;
}
fetch('/api/me', { headers: authHeaders(stored) })
.then(async (res) => {
if (!res.ok || cancelled) {
clearStoredSession();
setSession(null);
return;
}
const body = (await res.json()) as Me;
setSession(stored);
setMe(body);
})
.catch(() => {
if (!cancelled) {
clearStoredSession();
setSession(null);
}
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, [authHeaders]);
const loadTables = useCallback(async (s: string) => {
const res = await fetch('/api/admin/tables', { headers: authHeaders(s) });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name));
}, [authHeaders]);
const loadRows = useCallback(
async (s: string, name: string) => {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
setRowsData(await res.json());
},
[authHeaders],
);
// Enter the grid once /api/me confirms an admin.
useEffect(() => {
if (!session || !me?.admin) return;
let cancelled = false;
(async () => {
try {
await loadTables(session);
if (!cancelled) setBusy(false);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [session, me, loadTables]);
const openTable = async (name: string) => {
if (!session) return;
setBusy(true);
setError(null);
try {
await loadRows(session, name);
setTable(name);
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const refresh = async () => {
if (!session || !table) return;
setBusy(true);
setError(null);
try {
await loadRows(session, table);
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const login = async () => {
if (!username || !password) return;
setLoginError(null);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
return;
}
const body = (await res.json()) as { sessionId: string; user: Me };
storeSession(body.sessionId);
setSession(body.sessionId);
setMe(body.user);
setUsername('');
setPassword('');
};
const logout = async () => {
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
clearStoredSession();
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
setSession(null);
setMe(null);
setTable(null);
setRowsData(null);
setTables([]);
setError(null);
};
if (checking) return <div className="admin-panel">loading…</div>;
// No valid session → login form.
if (!session || !me) {
return (
<form
className="admin-panel admin-login"
onSubmit={(e) => {
e.preventDefault();
void login();
}}
>
<label>
Username
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input
data-testid="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{loginError && (
<div data-testid="admin-error" className="admin-error">
{loginError}
</div>
)}
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
Log in
</button>
</form>
);
}
// Authenticated but not an admin.
if (!me.admin) {
return (
<div className="admin-panel">
<p data-testid="admin-not-admin" className="admin-message">
Logged in as <strong>{me.username}</strong> — this account is not an admin.
</p>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
);
}
const columns = rowsData?.columns ?? [];
// Displayed grid: server rows plus one trailing empty row when "+ Add row" is active.
const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0);
const cellValue = (rowIdx: number, col: string): string => {
if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? '');
const key = `${rowIdx}:${col}`;
if (key in edits) return edits[key];
const raw = rowsData?.rows[rowIdx]?.[col];
return raw === null || raw === undefined ? '' : String(raw);
};
const saveRow = async (rowIdx: number) => {
if (!session || !table || !rowsData) return;
setBusy(true);
setError(null);
try {
if (newRow && rowIdx === rowsData.rows.length) {
// New row → POST the non-empty cells.
const values: Record<string, string> = {};
for (const col of columns) {
if (col === '__rowid') continue;
const v = cellValue(rowIdx, col);
if (v !== '') values[col] = v;
}
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders(session) },
body: JSON.stringify({ values }),
});
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
} else {
// Existing row → PUT only the changed cells (cleared cell = NULL).
const values: Record<string, string | null> = {};
for (const col of columns) {
if (col === '__rowid') continue;
const original = rowsData.rows[rowIdx]?.[col];
const origStr = original === null || original === undefined ? '' : String(original);
const v = cellValue(rowIdx, col);
if (v !== origStr) values[col] = v === '' ? null : v;
}
if (Object.keys(values).length === 0) return; // nothing changed
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...authHeaders(session) },
body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }),
});
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
}
await loadRows(session, table); // re-fetch so the grid shows server state (persistence check)
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const deleteRow = async (rowIdx: number) => {
if (!session || !table || !rowsData?.rows[rowIdx]) return;
setBusy(true);
setError(null);
try {
const rowid = rowsData.rows[rowIdx]?.__rowid;
const res = await fetch(
`/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`,
{ method: 'DELETE', headers: authHeaders(session) },
);
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
await loadRows(session, table);
setEdits({});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="admin-panel">
<div className="admin-toolbar">
<span data-testid="admin-user" className="admin-message">
{me.username} (admin)
</span>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
{!table && (
<>
<p className="admin-message">Pick a table:</p>
<div data-testid="admin-tables" className="admin-table-list">
{tables.map((t) => (
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
{t}
</button>
))}
</div>
</>
)}
{table && rowsData && (
<>
<div className="admin-toolbar">
<strong data-testid="admin-current-table">{table}</strong>
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
← tables
</button>
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
Refresh
</button>
<button
type="button"
data-testid="admin-add-row"
className="pane-btn"
disabled={busy || newRow}
onClick={() => setNewRow(true)}
>
+ Add row
</button>
</div>
<div className="admin-rows-wrap">
<table data-testid="admin-rows" className="admin-rows">
<thead>
<tr>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
<th />
</tr>
</thead>
<tbody>
{Array.from({ length: rowCount }, (_, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c}>
<input
data-testid={`admin-cell-${i}-${c}`}
className="admin-cell"
value={cellValue(i, c)}
disabled={busy || c === '__rowid'}
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
/>
</td>
))}
<td className="admin-row-actions">
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
Save
</button>{' '}
{!(newRow && i === rowsData.rows.length) && (
<>
<button
type="button"
data-testid={`admin-delete-${i}`}
className="pane-btn"
disabled={busy}
onClick={() => void deleteRow(i)}
>
Delete
</button>{' '}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{error && (
<div data-testid="admin-error" className="admin-error">
{error}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { formatCash } from '@/lib/format';
import type { PlayerSummary } from '@/lib/game/state';
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
// reputation desc; cash then username break ties deterministically so rows don't shuffle
// between snapshots when two players share a reputation.
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
const sorted = [...players].sort(
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
);
return (
<div className="leaderboard">
<table data-testid="leaderboard-table">
<thead>
<tr>
<th>Player</th>
<th>Cash</th>
<th>Reputation</th>
<th>Planes</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => (
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
<td>{p.username}</td>
<td>{formatCash(p.cash)}</td>
<td>{p.reputation}</td>
<td>{p.planeCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+4
View File
@@ -157,6 +157,10 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
> >
<div className="plane-main"> <div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span> <span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
{p.ownerName}
</span>
{p.status === 'flying' ? ( {p.status === 'flying' ? (
<> <>
<span className="plane-status status-flying">in flight</span> <span className="plane-status status-flying">in flight</span>
+58 -7
View File
@@ -11,12 +11,19 @@ import type { HoverTarget } from './hoverTypes';
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime'; import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal';
import AdminPanel from './AdminPanel';
import Leaderboard from './Leaderboard';
import { clearStoredSession, sessionHeaders } from '../lib/authClient';
const EUROPE_CENTER: [number, number] = [10, 52]; // v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
const INITIAL_ZOOM = 4; // Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
// so both continents are fully visible on a typical desktop map pane.
const WORLD_CENTER: [number, number] = [0, 0];
const INITIAL_ZOOM = 2;
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at // Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value. // European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
export const MIN_ZOOM = 3; export const MIN_ZOOM = 0; // v0.70.0: fully zoomed out the whole earth (512 px world) fits any viewport
export const MAX_ZOOM = 11; export const MAX_ZOOM = 11;
const AIRPORTS_SOURCE_ID = 'airports'; const AIRPORTS_SOURCE_ID = 'airports';
@@ -64,11 +71,16 @@ interface MapProps {
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared // ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
// shell state — same callback RightPane receives, used by the airport popup's Relocate row. // shell state — same callback RightPane receives, used by the airport popup's Relocate row.
applySnapshot: (snapshot: StateSnapshot) => void; applySnapshot: (snapshot: StateSnapshot) => void;
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
onLogout?: () => void;
// v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button.
isAdmin?: boolean;
} }
// Named FlightMap (not Map): a component called `Map` would shadow the global // Named FlightMap (not Map): a component called `Map` would shadow the global
// Map constructor inside its own body, breaking `new Map(...)` for the airport index. // Map constructor inside its own body, breaking `new Map(...)` for the airport index.
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event // /api/airports is fetched at most once per page life: every styledata event
@@ -86,6 +98,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null); const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null); const [airportCount, setAirportCount] = useState<number | null>(null);
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID); const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
// stored session on every open.
const [adminOpen, setAdminOpen] = useState(false);
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
// Push the latest planes/routes GeoJSON into whatever sources currently exist. // Push the latest planes/routes GeoJSON into whatever sources currently exist.
// No-op until the map (and for routes: the airport index) is ready — the mount // No-op until the map (and for routes: the airport index) is ready — the mount
@@ -218,7 +235,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
center: EUROPE_CENTER, center: WORLD_CENTER,
zoom: INITIAL_ZOOM, zoom: INITIAL_ZOOM,
minZoom: MIN_ZOOM, minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM, maxZoom: MAX_ZOOM,
@@ -287,7 +304,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const action = planeStatus === 'available' ? 'fly' : 'queue'; const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, { const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, // v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
body: JSON.stringify({ destIcao: p.icao }), body: JSON.stringify({ destIcao: p.icao }),
cache: 'no-store', cache: 'no-store',
}); });
@@ -308,12 +326,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
if (!f) return; if (!f) return;
const p = f.properties as PlaneFeatureProps; const p = f.properties as PlaneFeatureProps;
const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : ''; const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : '';
// v1.12.0: phase label next to the status — "flying · taking off" etc. plane-status keeps
// the raw status so existing assertions on it stay exact.
const phaseLine = p.phaseLabel ? `<span data-testid="plane-phase"> · ${p.phaseLabel}</span>` : '';
new maplibregl.Popup({ offset: 12 }) new maplibregl.Popup({ offset: 12 })
.setLngLat(f.geometry.coordinates as [number, number]) .setLngLat(f.geometry.coordinates as [number, number])
.setHTML( .setHTML(
`<div data-testid="plane-popup">` + `<div data-testid="plane-popup">` +
`<strong>${escapeHtml(p.modelLabel)}</strong><br/>` + `<strong>${escapeHtml(p.modelLabel)}</strong><br/>` +
`<span data-testid="plane-status">${p.status}</span><br/>` + `<span data-testid="plane-status">${p.status}</span>${phaseLine}<br/>` +
`fuel <span data-testid="plane-fuel">${p.fuelText}</span>` + `fuel <span data-testid="plane-fuel">${p.fuelText}</span>` +
routeLine + routeLine +
`</div>`, `</div>`,
@@ -553,6 +574,14 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available))); void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
}; };
// v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort
// server-side session kill, drop the stored id, tell the shell to show sign-in again.
const onTopBarLogout = async () => {
await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined);
clearStoredSession();
onLogout?.();
};
return ( return (
<div className="map-wrap"> <div className="map-wrap">
<header data-testid="app-header" className="app-header"> <header data-testid="app-header" className="app-header">
@@ -576,8 +605,30 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
<span data-testid="airport-count"> <span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`} {airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span> </span>
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
Leaderboard
</button>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
{isAdmin && (
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin
</button>
)}
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
Log out
</button>
</header> </header>
<div ref={containerRef} data-testid="map" className="map-container" /> <div ref={containerRef} data-testid="map" className="map-container" />
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel onLogout={onLogout} />
</Modal>
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
<Leaderboard players={state?.players ?? []} />
</Modal>
</div> </div>
); );
} }
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, type ReactNode } from 'react';
// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack),
// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it).
// Foundation for future modal-based features; keep this generic.
export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h2>{title}</h2>
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
×
</button>
</header>
{children}
</div>
</div>
);
}
+19 -12
View File
@@ -46,6 +46,15 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
}); });
} }
// v1.11.0 (ISS-159): the open packages the market list shows — hidden while no GROUNDED plane sits at
// their origin ("jobs for planes that are in air" disappear until landing). Payload-blocked rows stay:
// a grounded plane at the origin keeps the row visible with its disabled Load button.
export function visibleOpenPackages(state: StateSnapshot): PackageState[] {
return state.packages.filter(
(p) => p.status === 'open' && state.planes.some((pl) => pl.status === 'available' && pl.locationIcao === p.originIcao),
);
}
// Load controls for one open package: a single Load button when exactly one plane // Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when // is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its // several are, and a disabled button with the reason otherwise. Each row owns its
@@ -141,7 +150,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
if (!state) return null; if (!state) return null;
const clockMs = state.simState.clockMs; const clockMs = state.simState.clockMs;
const open = state.packages.filter((p) => p.status === 'open'); const open = visibleOpenPackages(state);
const catalog = state.catalog; const catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null; const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
@@ -209,6 +218,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{formatSpecialLabel(pkg.special)} {formatSpecialLabel(pkg.special)}
</span> </span>
)} )}
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
{pkg.carrierName && (
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
{pkg.carrierName}
</span>
)}
{eligible.length > 0 ? ( {eligible.length > 0 ? (
<LoadControls <LoadControls
pkg={pkg} pkg={pkg}
@@ -221,16 +236,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load Load
</button> </button>
)} )}
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
<button
type="button"
data-testid={`ignore-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
>
Ignore
</button>
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
@@ -238,8 +243,10 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span> <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". */} {/* 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> <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}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} {formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())}
</span> </span>
</div> </div>
</li> </li>
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useState } from 'react';
import { storeSession } from '@/lib/authClient';
// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is
// stored. Both forms POST the same shape ({username, password}); login mints a session for an
// existing account, register creates one first and then mints it. On success the sessionId goes
// to localStorage (via storeSession) and onAuthed() flips the shell back in.
interface SignInScreenProps {
onAuthed: () => void;
}
export default function SignInScreen({ onAuthed }: SignInScreenProps) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent<HTMLFormElement>, path: '/api/login' | '/api/register') {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const form = new FormData(e.currentTarget);
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: String(form.get('username') ?? ''),
password: String(form.get('password') ?? ''),
}),
cache: 'no-store',
});
const data = (await res.json()) as { sessionId?: string; error?: string };
if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`);
storeSession(data.sessionId);
onAuthed();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="sign-in">
<h1 data-testid="app-title">Flight Ops</h1>
{error !== null && (
<p data-testid="auth-error" role="alert" className="sign-in-error">
{error}
</p>
)}
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
<label htmlFor="sign-in-username">Username</label>
<input id="sign-in-username" data-testid="sign-in-username" name="username" autoComplete="username" required />
<label htmlFor="sign-in-password">Password</label>
<input id="sign-in-password" data-testid="sign-in-password" name="password" type="password" autoComplete="current-password" required />
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
Sign in
</button>
</form>
<p className="sign-in-divider">or</p>
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
<input id="register-username" name="username" autoComplete="off" required />
<label htmlFor="register-password">Password (at least 8 characters)</label>
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
Create account
</button>
</form>
</div>
);
}
+42 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { cargoWeightFor, loadEligiblePlanes } from './RightPane'; import { cargoWeightFor, loadEligiblePlanes, visibleOpenPackages } from './RightPane';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a // v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
@@ -27,6 +27,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
health: 100, // v0.39.0 (ISS-069) — full-health fixture default health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null, repairUntilSimTime: null,
queue: [], queue: [],
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
ownerName: 'kamilkrzywda',
...over, ...over,
}; };
} }
@@ -47,6 +49,8 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
...over, ...over,
}; };
} }
@@ -56,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes, planes,
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
packages, packages,
catalog: CATALOG, catalog: CATALOG,
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
@@ -129,3 +134,39 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
}); });
}); });
describe('visibleOpenPackages (market list row filter, ISS-159)', () => {
it('shows an open package when a grounded plane sits at its origin', () => {
const s = snap(
[plane({ id: 'at-origin' })],
[pkg({ id: 'x' }), pkg({ id: 'onboard', status: 'loaded', planeId: 'at-origin' })], // non-open statuses never listed
);
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']);
});
it('hides an open package when only a flying plane is at its origin (in air, not grounded)', () => {
const s = snap([plane({ id: 'en-route', status: 'flying', destIcao: 'LEMD' })], [pkg({ id: 'x' })]);
expect(visibleOpenPackages(s)).toEqual([]);
});
it('hides an open package when the grounded plane is elsewhere and another flies to its origin', () => {
const s = snap(
[
plane({ id: 'grounded-elsewhere', locationIcao: 'LEMD' }),
plane({ id: 'en-route', status: 'flying', destIcao: 'EDDF' }), // flying TO the package's origin — not grounded there yet
],
[pkg({ id: 'x' })],
);
expect(visibleOpenPackages(s)).toEqual([]);
});
it('keeps a payload-blocked row visible when a grounded plane at the origin has no room (disabled Load path preserved)', () => {
const packages = [
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 })), // 4 × 1200 = 4800 — full
pkg({ id: 'x' }),
];
const s = snap([plane({ id: 'full' })], packages);
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']); // row stays…
expect(loadEligiblePlanes(s, s.packages.find((p) => p.id === 'x')!)).toEqual([]); // …with Load disabled
});
});
+25 -347
View File
@@ -1118,367 +1118,45 @@
"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","齐齐哈尔, 齐齐哈尔三家子机场" "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" "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","沈阳, 沈阳桃仙国际机场" "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","" "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" "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",""
"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","" "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","" "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" "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","" "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","" "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" "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","" "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)","" "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",""
"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","" "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",""
"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","" "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",""
"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" "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","УЕСТ, Аеропорт Тикси"
"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","" "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","УХМП, Аэропорт Певек"
"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" "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"
"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","" "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","Аэропорт Омсукчан"
"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","" "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#",""
"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" "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"
"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"
"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"
"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",""
"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",""
"5059","NZWD","medium_airport","Williams Field Skiway","-77.867401","167.057007","68","AN","AQ","AQ-U-A","McMurdo Station","no","","","NZWD","","","https://en.wikipedia.org/wiki/Williams_Field","Willies Field"
"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","" "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" "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)","" "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ō" "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","УЕСУ, Аэропорт Зырянка" "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",""
"323626","RU-0472","small_airport","Lamutskoye Airport","65.538838","168.844848","601","AS","RU","RU-CHU","Lamutskoye","no","","","UHAL","UHAL","","","УХАЛ, Ламутское" "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",""
"338091","RU-0543","small_airport","Tempa Airfield","75.76713","137.576637","3","EU","RU","RU-SA","Kotelny Island","no","","","","","","","Темп" "603000","AQ-0016","medium_airport","Grandfather Skiway","-77.530242","-31.827393","","AN","AQ","AQ-U-A","Antartica","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, Аэропорт Анадырь" "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",""
"340795","RU-0605","small_airport","Uelkal Airstrip","65.530823","-179.287263","","EU","RU","RU-CHU","Uelkal","no","","","","","","","" "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"
"340810","RU-0616","small_airport","Taymylyr Airstrip","72.607271","121.893179","","EU","RU","RU-SA","Taymylyr","no","","","","","","","" "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"
"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","" "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","" "27793","YMNY","small_airport","Morney Airport","-25.358299255371094","141.43299865722656","104","OC","AU","AU-QLD","","no","YMNY","OXY","YMNY","","","",""
"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","" "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","" "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",""
"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, Аэропорт Ербогачен, Аэропорт Ербогачён" "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, Аэропорт Варандей" "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","","","УОДЛ, Челюскин" "44051","BGDH","small_airport","Danmarkshavn Landing Strip","76.76667","-18.666668","","NA","GL","GL-NE","Danmarkshavn","no","","","BGDH","","","",""
"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","" "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","","","","" "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"
"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
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 沈阳, 沈阳桃仙国际机场
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
1121 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
1122 28228 1726 CJN8 CYEV seaplane_base medium_airport Fort Reliance Seaplane Base Inuvik Mike Zubko Airport 62.7 68.30419921880001 -109.167 -133.483001709 514 224 NA CA CA-NT Fort Reliance Inuvik no yes CYEV YFL YEV CJN8 CYEV CJN8 https://en.wikipedia.org/wiki/Fort_Reliance_Water_Aerodrome https://en.wikipedia.org/wiki/Inuvik_(Mike_Zubko)_Airport 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
1123 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
1124 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
1125 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
1126 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
1127 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
1128 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
1129 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
1130 18251 5398 PAIW PAPG small_airport medium_airport Wales Airport Petersburg James A Johnson Airport 65.622593 56.80170059 -168.095 -132.9450073 22 111 NA US US-AK Wales Petersburg yes PAIW PAPG WAA PSG PAIW PAPG IWK PSG https://en.wikipedia.org/wiki/Wales_Airport_(Alaska) https://en.wikipedia.org/wiki/Petersburg_James_A._Johnson_Airport
1131 19891 5506 PAKF PWAK small_airport medium_airport False Pass Airport Wake Island Airfield 54.84751 19.282437 -163.407168 166.636637 20 14 NA OC US UM US-AK UM-79 False Pass Wake Island yes PAKF PWAK KFP AWK PAKF PWAK KFP AWK https://en.wikipedia.org/wiki/False_Pass_Airport https://en.wikipedia.org/wiki/Wake_Island_Airfield
1132 17980 6055 PAKO SEGS small_airport medium_airport Nikolski Air Station Seymour Galapagos Ecological Airport 52.941552 -0.453758 -168.849478 -90.2659 77 207 NA SA US EC US-AK EC-W Nikolski Isla Baltra yes PAKO SEGS IKO GPS PAKO SEGS IKO https://ecogal.aero/en/ https://en.wikipedia.org/wiki/Nikolski_Air_Station https://en.wikipedia.org/wiki/Seymour_Airport
1133 16165 6450 PALN UEST small_airport medium_airport Point Lonely Short Range Radar Site Airfield Tiksi Airport 70.911118 71.697700500488 -153.237766 128.90299987793 17 26 NA AS US RU US-AK RU-SA Point Lonely Tiksi no yes PALN UEST LNI IKS PALN UEST AK71 https://en.wikipedia.org/wiki/Point_Lonely_Short_Range_Radar_Site https://en.wikipedia.org/wiki/Tiksi_Airport Lonely Air Station УЕСТ, Аеропорт Тикси
1134 5385 6459 PALU UHMP medium_airport Cape Lisburne LRRS Airport Pevek Airport 68.875099 69.783302 -166.110001 170.597 16 11 NA AS US RU US-AK RU-CHU Cape Lisburne Apapelgino yes PALU UHMP LUR PWE PALU UHMP LUR https://en.wikipedia.org/wiki/Cape_Lisburne_LRRS_Airport https://en.wikipedia.org/wiki/Pevek_Airport УХМП, Аэропорт Певек
1135 5396 26895 PAPB YAYE medium_airport St George Airport Ayers Rock Connellan Airport 56.577345 -25.185913 -169.663823 130.97703 125 1626 NA OC US AU US-AK AU-NT St George Yulara yes PAPB YAYE STG AYQ PAPB YAYE PBV https://en.wikipedia.org/wiki/St._George_Airport_(Alaska) https://en.wikipedia.org/wiki/Ayers_Rock_Airport A8L Ayers Rock, Uluru
1136 24089 44659 PAPO UHMF small_airport medium_airport Point Hope Airport Omsukchan Airport 68.348801 62.457001 -166.798996 155.744995 12 1732 NA AS US RU US-AK RU-MAG Point Hope Omsukchan yes PAPO UHMF PHO PAPO UHMF PHO https://en.wikipedia.org/wiki/Point_Hope_Airport https://en.wikipedia.org/wiki/Omsukchan_Airport Аэропорт Омсукчан
1137 5404 318444 PASA FHSH medium_airport Savoonga Airport Saint Helena International Airport 63.686401 -15.958898 -170.492996 -5.646066 53 1017 NA AF US SH US-AK SH-SH Savoonga Jamestown yes PASA FHSH SVA HLE PASA FHSH SVA http://sthelenaairport.com/ https://en.wikipedia.org/wiki/Savoonga_Airport https://en.m.wikipedia.org/wiki/Saint_Helena_Airport#
1138 5410 1308 PASN CNH2 medium_airport small_airport St Paul Island Airport Natuashish Airport 57.166311 55.913898 -170.222555 -61.184399 63 30 NA US CA US-AK CA-NL St Paul Island Natuashish yes PASN SNP YNP PASN CNH2 SNP CNH2 https://en.wikipedia.org/wiki/St._Paul_Island_Airport https://en.wikipedia.org/wiki/Natuashish_Airport Pribilof NH2
1139 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
1140 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
1141 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
1142 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
1143 5059 NZWD medium_airport Williams Field Skiway -77.867401 167.057007 68 AN AQ AQ-U-A McMurdo Station no NZWD https://en.wikipedia.org/wiki/Williams_Field Willies Field
1144 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
1145 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
1146 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
1147 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ō
1148 42980 35041 UESU UODN medium_airport Zyryanka Airport Nagurskoye Airfield 65.7485 80.803207 150.8889 47.663586 140 59 AS RU RU-SA RU-ARK Zyryanka Alexandra Land yes no UESU UODN ZKP UESU UODN https://en.wikipedia.org/wiki/Zyryanka_Airport https://en.wikipedia.org/wiki/Nagurskoye УЕСУ, Аэропорт Зырянка
1149 323626 336953 RU-0472 AT03 small_airport medium_airport Lamutskoye Airport Concordia Skyway 65.538838 -75.103278 168.844848 123.35825 601 10725 AS AN RU AQ RU-CHU AQ-U-A Lamutskoye Concordia Station no UHAL AT03 UHAL https://en.wikipedia.org/wiki/Concordia_Station УХАЛ, Ламутское
1150 338091 603000 RU-0543 AQ-0016 small_airport medium_airport Tempa Airfield Grandfather Skiway 75.76713 -77.530242 137.576637 -31.827393 3 EU AN RU AQ RU-SA AQ-U-A Kotelny Island Antartica no Темп
1151 6456 11 UHMA NZ12 medium_airport small_airport Ugolny Yuri Ryktheu Airport Palmer Station Airport 64.734902 -64.774582 177.740997 -64.035907 194 149 AS AN RU AQ RU-CHU AQ-U-A Anadyr Palmer Station yes no UHMA DYR UHMA NZ0B NZ0B https://en.wikipedia.org/wiki/Ugolny_Airport https://en.wikipedia.org/wiki/Palmer_Station УХМА, Угольный, Аnadyr Airport, Аэропорт Анадырь
1152 340795 1486 RU-0605 CRE2 small_airport Uelkal Airstrip Edzo Airport 65.530823 62.766701 -179.287263 -116.084 587 EU NA RU CA RU-CHU CA-NT Uelkal Rae-Edzo no CRE2 CRE2 https://en.wikipedia.org/wiki/Rae/Edzo_Airport RE2
1153 340810 5534 RU-0616 RJAM small_airport Taymylyr Airstrip JMSDF Minami Torishima Air Base 72.607271 24.2897 121.893179 153.979004 22 EU AS RU JP RU-SA JP-13 Taymylyr Ogasawara no RJAM MUS RJAM https://en.wikipedia.org/wiki/Minami_Torishima_Airport Marcus Island Airport
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
1154 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
1155 45059 27793 SCGC YMNY small_airport Union Glacier Blue-Ice Runway Morney Airport -79.777778 -25.358299255371094 -83.320833 141.43299865722656 2461 104 AN OC AQ AU AQ-U-A AU-QLD Union Glacier no SCGC YMNY UGL OXY SCGC YMNY https://en.wikipedia.org/wiki/Union_Glacier_Blue-Ice_Runway
1156 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
1157 6055 39579 SEGS AQ-0001 medium_airport small_airport Seymour Galapagos Ecological Airport Novolazarevskaya Station -0.453758 -70.8461 -90.2659 11.8472 207 390 SA AN EC AQ EC-W AQ-U-A Isla Baltra Queen Maud Land yes no SEGS GPS SEGS AT17 https://ecogal.aero/en/ https://en.wikipedia.org/wiki/Seymour_Airport https://en.wikipedia.org/wiki/Novolazarevskaya_Station
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 УХЯЕ, Слаутное
1158 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, Аэропорт Ербогачен, Аэропорт Ербогачён
1159 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, Аэропорт Варандей
1160 315363 44051 UODL BGDH small_airport Cape Chelyuskin Airfield Danmarkshavn Landing Strip 77.712 76.76667 104.24 -18.666668 60 AS NA RU GL RU-KYA GL-NE Chelyuskin Polar Station Danmarkshavn no UODL BGDH 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
1161 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
1162 38645 330063 YWLA AT25 small_airport Willowra Airport Showa Station Skiway -21.27669906616211 -69.006167 132.6230010986328 39.59 95 OC AN AU AQ AU-NT AQ-U-A Showa Station no YWLA AT25 https://en.wikipedia.org/wiki/Showa_Station_(Antarctica) Syowa Station
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 image: flight-ops-ci
environment: environment:
FLIGHT_TEST_HOOKS: "1" 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: volumes:
# Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public # 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 # before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's
+11 -5
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
// via the right pane → click its destination in the on-board list (left pane since // via the right pane → click its destination in the on-board list (left pane since
@@ -15,6 +20,7 @@ import { windBurnFactor } from '../lib/sim/movement';
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions. // penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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) const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
@@ -329,7 +335,7 @@ 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('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 test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page); await flyHomeViaApi(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must // A: load, let it expire while loaded, then FLY to its destination — arrival must
@@ -345,12 +351,12 @@ test('expired packages deliver and abandon with no reward and no penalty', async
} }
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry // 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 // window so scale 10_000 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. // the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page); await activateWorld(page);
try { try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE); await setScale(page, FAST);
let s = await getState(page); 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'); await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page); s = await getState(page);
@@ -382,9 +388,9 @@ 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.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: top up + fly home at SCALE. // Restore: top up + fly home at FAST (pure arrival wait).
await setFuelFull(page, 'plane-starter'); await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE); await setScale(page, FAST);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy(); expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
+113
View File
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';
// v0.71.0 — Admin panel e2e: top-bar button → modal; wrong credentials show an error; a session
// minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates WITHOUT the real
// admin password ever appearing in test code; then the generic DB grid is exercised end-to-end:
// table list → edit + save a cell (verified by an independent re-fetch) → add a row → delete it →
// logout. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated
// visitors see IT instead of the shell, and logout drops back to it.
const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
const res = await page.request.post('/api/test/admin-session');
expect(res.status(), 'admin-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
return ((await res.json()) as { sessionId: string }).sessionId;
}
test('admin panel: sign-in gate, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
await page.goto('/');
// --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107).
const signIn = page.getByTestId('sign-in-form');
await expect(signIn).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('app-header')).toHaveCount(0);
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
await page.getByTestId('sign-in-username').fill('kamilkrzywda');
await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose');
await page.getByTestId('sign-in-submit').click();
await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 });
// --- Authenticate via the test hook + localStorage, then reload into the shell.
const sessionId = await mintAdminSession(page);
// Both values must be passed as arguments — the function runs in the browser, where Node-side
// variables are not in scope.
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
// The independent re-fetch below goes through page.request → needs the header at context level.
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
await page.reload();
const adminBtn = page.getByTestId('admin-btn');
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
// --- Open the modal: stored session is valid → straight to the table list.
await adminBtn.click();
// --- Authenticated admin → table list including packages and users.
const tables = page.getByTestId('admin-tables');
await expect(tables).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('admin-table-packages')).toBeVisible();
await expect(page.getByTestId('admin-table-users')).toBeVisible();
// --- Open packages, edit one cell, Save; verify via an INDEPENDENT re-fetch of the same route.
await page.getByTestId('admin-table-packages').click();
const grid = page.getByTestId('admin-rows');
await expect(grid).toBeVisible({ timeout: 30_000 });
const rowidCell = page.getByTestId('admin-cell-0-__rowid');
const originalRowid = Number(await rowidCell.inputValue());
expect(Number.isInteger(originalRowid)).toBeTruthy();
const rewardCell = page.getByTestId('admin-cell-0-reward');
await rewardCell.fill('987654');
await page.getByTestId('admin-save-0').click();
// The grid re-fetches after save — the edited cell shows the new value…
await expect(rewardCell).toHaveValue('987654', { timeout: 15_000 });
// …and an independent request (not the grid's) confirms it persisted server-side.
const check = await page.request.get('/api/admin/tables/packages/rows?limit=500', {
headers: { 'X-Flight-Session': sessionId },
});
expect(check.status()).toBe(200);
const fetched = (await check.json()) as { rows: Array<Record<string, unknown>> };
const row = fetched.rows.find((r) => Number(r.__rowid) === originalRowid);
expect(row, 'edited packages row must still exist').toBeTruthy();
expect(Number(row?.reward)).toBe(987654);
// --- Add a row (users table — fewest required columns), then delete it again.
await page.getByTestId('admin-back').click();
await expect(tables).toBeVisible();
await page.getByTestId('admin-table-users').click();
const usersGrid = page.getByTestId('admin-rows');
await expect(usersGrid).toBeVisible({ timeout: 30_000 });
const existingRows = await page.getByTestId(/admin-save-\d+/).count();
await page.getByTestId('admin-add-row').click();
const n = existingRows; // the new empty row is appended last
await page.getByTestId(`admin-cell-${n}-id`).fill('user-e2e-admin-test');
await page.getByTestId(`admin-cell-${n}-username`).fill('e2e-admin-user');
await page.getByTestId(`admin-cell-${n}-password_hash`).fill('aa$bb'); // any string — dev-tool insert, not a real credential
await page.getByTestId(`admin-cell-${n}-created_at`).fill(String(Date.now()));
await page.getByTestId(`admin-save-${n}`).click();
// The inserted row is visible after the grid's post-save re-fetch.
const bodyRows = usersGrid.locator('tbody tr');
const findUserRow = async (): Promise<number> => {
const count = await bodyRows.count();
for (let i = 0; i < count; i++) {
if ((await page.getByTestId(`admin-cell-${i}-username`).inputValue()) === 'e2e-admin-user') return i;
}
return -1;
};
await expect.poll(findUserRow, { timeout: 15_000 }).toBeGreaterThanOrEqual(0);
const addedIdx = await findUserRow();
// Delete it again — gone after the post-delete re-fetch.
await page.getByTestId(`admin-delete-${addedIdx}`).click();
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
// --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell).
await page.getByTestId('admin-logout').click();
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
await expect(tables).toBeHidden();
});
+9 -3
View File
@@ -13,26 +13,32 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all eighteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> }; const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
expect(spec.info.title).toBe('Flight Ops API'); expect(spec.info.title).toBe('Flight Ops API');
expect(spec.info.version).toBe(pkg.version); expect(spec.info.version).toBe(pkg.version);
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly. // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly;
// v0.71.0 adds the admin panel (login/logout/me + guarded DB CRUD).
expect(Object.keys(spec.paths).sort()).toEqual([ expect(Object.keys(spec.paths).sort()).toEqual([
'/api/admin/tables', // v0.71.0 — admin panel
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/login', // v0.71.0 — admin panel auth
'/api/logout', // v0.71.0
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel', '/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
+34
View File
@@ -0,0 +1,34 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
// v1.1.0 (ISS-107) — session helpers for the e2e suite. Every game route is now session-guarded, so
// each spec authenticates in a beforeEach: mint a player session server-side via the test hook
// (FLIGHT_TEST_HOOKS=1; no real credentials in test code), then apply it BOTH ways the client uses —
// localStorage for the app's own fetches (lib/authClient.ts reads it) and context extraHTTPHeaders
// so page.request.* calls in specs carry X-Flight-Session too.
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
// usual local player, exactly as before.
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
return ((await res.json()) as { sessionId: string }).sessionId;
}
export async function applySession(page: Page, sessionId: string): Promise<void> {
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
// Runs before every navigation's scripts — the app reads the session from localStorage on load.
await page.addInitScript(
([key, sid]) => window.localStorage.setItem(key, sid),
[SESSION_KEY, sessionId],
);
}
export async function authenticate(page: Page): Promise<string> {
const sessionId = await mintPlayerSession(page);
await applySession(page, sessionId);
return sessionId;
}
+9 -3
View File
@@ -12,11 +12,17 @@ interface StateBody {
} }
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history // 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. // (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']); const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
test('GET /api/events streams state snapshots over SSE', async () => { test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } }); // v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client
// passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path.
const mint = await request.post('/api/test/mint-player-session');
expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
const { sessionId } = (await mint.json()) as { sessionId: string };
const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.body).not.toBeNull(); expect(res.body).not.toBeNull();
+23 -3
View File
@@ -63,10 +63,30 @@ test('long-leg priority packages always expire after travel time at max speed +
created.push(pkg); created.push(pkg);
} }
} finally { } finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board. // Cleanup: age out every package this test created so it leaves no trace on the shared board.
for (const pkg of created) { for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`); const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy(); expect(r.ok(), `expiring ${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
});
+44 -28
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map. // v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying); // Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
// open-package rows highlight both endpoint airports. The map exposes three top-most // open-package rows highlight both endpoint airports. The map exposes three top-most
@@ -12,6 +17,7 @@ import { test, expect, type Page } from '@playwright/test';
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). // additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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 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. // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
@@ -162,8 +168,8 @@ async function unhover(page: Page): Promise<void> {
} }
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns // Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
// features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves // features rendered inside the CURRENT viewport, and the v0.70.0 default world view ([0,0] z2) leaves
// far endpoints (LEMD) off-screen even when they match a layer filter. // far endpoints (e.g. NFTF) off-screen even when they match a layer filter.
async function fitToAirports(page: Page, icaos: string[]): Promise<void> { async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as { const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>; features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
@@ -321,8 +327,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); 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 }); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen // Both endpoints must be in-viewport for queryRenderedFeatures to see them.
// at the default Frankfurt-centered view).
await fitToAirports(page, [HUB, DEST]); await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
@@ -361,14 +366,17 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0); }).toBe(0);
}); });
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great // ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and // (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line. // 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('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 test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF'); const pkg = await createPackage(page, HUB, 'NFTF');
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -376,11 +384,14 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); 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 }); 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. // fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
await fitToAirports(page, ['NZAA', 'NFTF']); // ~176° of longitude — fitting it needs z≈1, below the old MIN_ZOOM=3 floor; since v0.70.0 minZoom is
// natively 0 (components/Map.tsx), no setMinZoom workaround is needed anymore. Quantization stays
// ≤~0.02° even near z=0, below EPS (:214–228).
await fitToAirports(page, [HUB, 'NFTF']);
await row.hover(); await row.hover();
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120) 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. // The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
await expect.poll(async () => { await expect.poll(async () => {
@@ -506,7 +517,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}); });
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => { test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -515,23 +526,26 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up. // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
// post-action snapshot already shows the plane flying — no poll race.
const pkg = await createPackage(page, HUB, DEST); const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200); expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); const flyBody = (await fly.json()) as StateBody;
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
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
// test below).
await setScale(page, 1);
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054). // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('plane-plane-starter'); const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); 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): 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 block.hover();
await expect.poll(async () => { await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>; const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
@@ -544,9 +558,10 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
message: 'waiting for the highlight to clear after mouseleave', message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0); }).toBe(0);
// Resume and fast-forward to arrival, then fly home so the live instance stays in its // Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
// onboarding state (starter grounded at EDDF) for the other specs. // stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
await setScale(page, SCALE); // arrivals → FAST (~1 s each instead of ~10 s).
await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -554,8 +569,8 @@ 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}` }) }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true); .toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect await expect
@@ -567,7 +582,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
}); });
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => { test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -641,7 +656,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
// not DEST — it never delivered), then fly home. // not DEST — it never delivered), then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200); expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -652,8 +668,8 @@ 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`); 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 expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
// Re-set SCALE for the return leg. // Re-set FAST for the return leg (~1 s instead of ~10 s).
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect await expect
+5
View File
@@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test';
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants. // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
import { REPAIR_COST } from '../lib/game/catalog'; import { REPAIR_COST } from '../lib/game/catalog';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live // v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows // origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts // the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
+51 -19
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"): // v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
// expanded airport popup (name + ICAO kept, plus approximate local time and a // expanded airport popup (name + ICAO kept, plus approximate local time and a
// deterministic generated weather), plane markers whose positions come from the // deterministic generated weather), plane markers whose positions come from the
@@ -15,6 +20,9 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// the 6300 L tank, so a full-tank departure always makes it home. // the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD'; const DEST = 'LEMD';
const SCALE = 1000; // test rate for fast-forwarding legs const SCALE = 1000; // test rate for fast-forwarding legs
// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish
// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate.
const FAST = 50_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface PlaneState { interface PlaneState {
@@ -101,15 +109,15 @@ 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); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Load a package onto plane-starter and fly it from → to via the API, then poll at // Load a package onto plane-starter and fly it from → to via the API, then fast-forward at FAST
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts. // (pure arrival wait — no mid-leg observation) until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> { async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const pkg = await createPackage(page, fromIcao, toIcao); const pkg = await createPackage(page, fromIcao, toIcao);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200); expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const body = await getState(page); const body = await getState(page);
@@ -121,7 +129,7 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
} }
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over // 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 = []. // the area of interest first ([] until then). Same trap as run-176: default world view + Pacific plane = [].
function sourceFeatures(page: Page, sourceId: string) { function sourceFeatures(page: Page, sourceId: string) {
return page.evaluate((srcId: string) => { return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap; const m = (window as any).__flightMap;
@@ -155,7 +163,23 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
{ timeout: 15_000 }, { timeout: 15_000 },
).toBeGreaterThan(0); ).toBeGreaterThan(0);
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view
// ([0,0] z2) shows both Africa + Europe with overlapping markers (nearest neighbour LFRB Brest is
// only ~13–19 px away at z2), so frame LFRS at z5 first — its nearest marker is then ~104 px away.
await page.evaluate(() => {
(window as any).__flightMap.jumpTo({ center: [-1.61073, 47.153198], zoom: 5 });
});
// Wait until LFRS's marker is actually rendered at the new camera before clicking it.
await expect.poll(async () =>
page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === 'LFRS');
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(() => { const pt = await page.evaluate(() => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
@@ -183,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
}); });
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => { test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -194,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route',
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter'); const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
expect(foundStarter).toBeDefined(); expect(foundStarter).toBeDefined();
if (foundStarter!.status === 'flying') { if (foundStarter!.status === 'flying') {
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
await expect await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
timeout: 240_000, timeout: 240_000,
@@ -281,6 +305,11 @@ test('plane markers move while flying and the plane popup shows status + route',
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' }) }, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true); .toBe(true);
// queryRenderedFeatures is viewport-scoped, and at the v0.70.0 default view ([0,0] z2) this pane
// only shows latitudes ~±51° — EDDF (50°N) sits right at its edge, so frame both endpoints of the
// leg to guarantee the plane marker is rendered wherever it is on it.
await fitToAirports(page, [HUB, DEST]);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1 // 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 // 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 // poll, so right after the freeze confirmation the last rendered frame can still be up to
@@ -322,8 +351,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. // 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}`); await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Restore SCALE before step 5 — the landing wait needs a fast clock. // Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s.
await setScale(page, SCALE); await setScale(page, FAST);
// 5) Let the flight finish, then fly home so the live instance stays in its // 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 // onboarding state (starter grounded at EDDF) for the other specs. The loaded
@@ -452,7 +481,7 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
} }
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { 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 test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -463,7 +492,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain. // 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); 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') { if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); 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 // Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
@@ -474,7 +503,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
for (const next of await chainTo(page, 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 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); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
} }
} }
@@ -495,13 +524,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
const pkg = await createPackage(page, ORIGIN, CROSS_DEST); const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); expect((await page.request.post(`/api/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')!)}`); console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
// The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the
// >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST
// rate would let the dispatch tick itself consume most of the leg before this setScale lands.
await setScale(page, SCALE);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } }); 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)`); 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')!)}`); 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 // querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
// Pacific: fit the camera over both endpoints first or the plane feature never appears in queries. // Pacific leg: fit the camera over both endpoints first or the plane feature never appears in queries.
await fitToAirports(page, [ORIGIN, CROSS_DEST]); await fitToAirports(page, [ORIGIN, CROSS_DEST]);
// Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND // Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND
@@ -565,16 +597,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
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(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 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 { } finally {
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical // Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other
// state. The package was delivered on arrival (generous deadline). // specs start from the canonical state. The package was delivered on arrival (generous deadline).
await setScale(page, SCALE); await setScale(page, FAST);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); 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; const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (endLoc !== HUB) { if (endLoc !== HUB) {
for (const next of await chainTo(page, endLoc, HUB)) { for (const next of await chainTo(page, endLoc, HUB)) {
await setFuelFull(page, 'plane-starter'); await setFuelFull(page, 'plane-starter');
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
} }
} }
+74 -17
View File
@@ -2,15 +2,43 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
const APP_URL = '/'; const APP_URL = '/';
// Read at test time so the assertion tracks package.json automatically. // Read at test time so the assertion tracks package.json automatically.
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx. // Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
const MIN_ZOOM = 3; const MIN_ZOOM = 0; // v0.70.0: lowered 3 → 0 so the whole earth is visible when fully zoomed out
const MAX_ZOOM = 11; const MAX_ZOOM = 11;
// LFRS Nantes Atlantique — the marker-click target of this file (and e2e/map-interactions.spec.ts,
// e2e/relocation.spec.ts). The v0.70.0 default view ([0,0] z2) shows both Africa + Europe with
// overlapping markers (nearest neighbour LFRB Brest is only ~13–19 px away at z2), so every click
// test first frames LFRS at z5 — its nearest marker is then ~104 px away and the projected center
// cannot hit a neighbouring circle.
const LFRS: [number, number] = [-1.61073, 47.153198];
async function frameLfrs(page: Page): Promise<void> {
await page.evaluate((c) => (window as any).__flightMap.jumpTo({ center: c, zoom: 5 }), LFRS);
// Wait until LFRS's marker is actually rendered at the new camera before clicking it. The
// coordinate is passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope
// values don't exist (same trap as e2e/relocation.spec.ts).
await expect.poll(async () =>
page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project(c);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === 'LFRS');
}, LFRS),
{ timeout: 15_000 },
).toBe(true);
}
async function waitForAirportCount(page: Page): Promise<number> { async function waitForAirportCount(page: Page): Promise<number> {
const countEl = page.getByTestId('airport-count'); const countEl = page.getByTestId('airport-count');
await expect.poll(async () => { await expect.poll(async () => {
@@ -27,6 +55,16 @@ test('page loads with the map and world airport count', async ({ page }) => {
await expect(page.getByTestId('map')).toBeVisible(); await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page); const count = await waitForAirportCount(page);
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0);
// v0.70.0 default view: null island [0,0] at zoom 2 (Africa + Europe both visible). The map
// instance exists by the time the airport count renders — it is created before airports load.
const view = await page.evaluate(() => {
const m = (window as any).__flightMap;
return { lng: m.getCenter().lng, lat: m.getCenter().lat, z: m.getZoom() };
});
expect(view.z).toBeCloseTo(2);
expect(view.lng).toBeCloseTo(0);
expect(view.lat).toBeCloseTo(0);
}); });
test('header shows the app version sourced from package.json', async ({ page }) => { test('header shows the app version sourced from package.json', async ({ page }) => {
@@ -40,6 +78,17 @@ test('header shows the app version sourced from package.json', async ({ page })
); );
}); });
// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button
// (mirrors e2e/admin.spec.ts' logout assertion).
test('top bar Log out drops back to the sign-in screen', async ({ page }) => {
await page.goto(APP_URL);
const header = page.getByTestId('app-header');
await expect(header).toBeVisible({ timeout: 30_000 });
await page.getByTestId('logout-btn').click();
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
await expect(header).toHaveCount(0);
});
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => { test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
await page.goto(APP_URL); await page.goto(APP_URL);
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -56,18 +105,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 }, { timeout: 15_000 },
).toBeGreaterThan(0); ).toBeGreaterThan(0);
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it. // Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why
// Nantes is used because the v0.9.0 three-pane layout narrows the map column (~560 px at the // the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
// default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays await frameLfrs(page);
// isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and const pt = await page.evaluate((c) => {
// LFBD Bordeaux, ~39 px away at the initial zoom [10,52] z4 (verified against data/airports_world.csv),
// so its marker cannot be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect(); const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y }; return { x: rect.left + p.x, y: rect.top + p.y };
}); }, LFRS);
await page.mouse.click(pt.x, pt.y); await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup'); const popup = page.getByTestId('airport-popup');
@@ -138,12 +184,18 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
await waitForStableZoom(); await waitForStableZoom();
expect(await getZoom()).toBeCloseTo(MAX_ZOOM); expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
// Zoom out well below MIN_ZOOM with several large deltas. // Zoom out to the effective minimum. MapLibre won't zoom out past the point where
await jumpTo(MIN_ZOOM + 1); // the world no longer covers the viewport height (fit floor = log2(h/512)), so with
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9); // MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see
// the whole Earth", which is exactly what min zoom should give.
const mapBox = await page.getByTestId('map').boundingBox();
if (!mapBox) throw new Error('map bounding box unavailable');
const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512));
await jumpTo(minLimit + 1);
await wheelToLimit(600, (z) => z <= minLimit + 1e-9);
await waitForStableZoom(); await waitForStableZoom();
const zoom = await getZoom(); const zoom = await getZoom();
expect(zoom).toBeCloseTo(MIN_ZOOM); expect(zoom).toBeCloseTo(minLimit);
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM); expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM); expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
}); });
@@ -189,6 +241,9 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect(page.getByTestId('airport-count')).toContainText(/world airports/); await expect(page.getByTestId('airport-count')).toContainText(/world airports/);
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0); await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
// Clicking a marker after the switch opens exactly one popup (the click handler is // Clicking a marker after the switch opens exactly one popup (the click handler is
// re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our // re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so // custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
@@ -258,12 +313,14 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
{ timeout: 15_000 }, { timeout: 15_000 },
).toBeGreaterThan(0); ).toBeGreaterThan(0);
const pt = await page.evaluate(() => { // Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect(); const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y }; return { x: rect.left + p.x, y: rect.top + p.y };
}); }, LFRS);
await page.mouse.click(pt.x, pt.y); await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup'); const popup = page.getByTestId('airport-popup');
+283
View File
@@ -0,0 +1,283 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { applySession, authenticate, mintPlayerSession } from './auth';
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
// player; player B is a second account minted through the same hook with a username override (the
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
//
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs
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 LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
interface PlaneState {
id: string;
status: string;
locationIcao: string;
maxFuel: number;
queue: string[];
ownerId: string;
ownerName: string;
}
interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number };
planes: PlaneState[];
players: PlayerSummary[];
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
for (let attempt = 1; ; attempt++) {
const res = await page.request.get('/api/state');
if (res.status() === 200) return (await res.json()) as StateBody;
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
await page.waitForTimeout(500);
}
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
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);
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
async function nearestToHub(page: Page): Promise<string> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)[0].icao;
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const sessionId = await mintPlayerSession(page, PLAYER_B);
await applySession(page, sessionId);
return { ctx, page };
}
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
async function healStarterHome(page: Page): Promise<void> {
await clearQueueViaApi(page, STARTER);
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight starter to land during cleanup',
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
}
if (p.locationIcao !== HUB) {
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for the starter to land back at the hub during cleanup',
})
.toBe(HUB);
}
}
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
async function waitForGroundedFleet(page: Page): Promise<void> {
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
timeout: 120_000,
message: 'waiting for the fleet to be fully grounded',
})
.toBe('idle');
}
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
test.beforeEach(async ({ page }) => {
await authenticate(page); // player A — the seeded local player that owns plane-starter
});
test.afterEach(async ({ page }) => {
try {
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
await healStarterHome(page);
await setScale(page, WORLD);
} finally {
if (bCtx) await bCtx.close();
bCtx = null;
}
});
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
test.afterAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
// Verify through the same surface the game uses — mint a local session just for this read.
const sessionId = await mintPlayerSession(page);
await applySession(page, sessionId);
const s = (await getState(page)) as StateBody;
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
} finally {
await ctx.close();
}
});
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
const b = await mintPlayerB(browser);
bCtx = b.ctx;
await waitForGroundedFleet(page);
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
const dest = await nearestToHub(page);
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
await setFuelFull(page, STARTER);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(500);
const s = await getState(page); // tick-on-read — advances while any plane flies
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
const now = s.simState.clockMs;
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
prev = now;
}
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: "waiting for A's plane to land",
})
.toBe('available');
const frozen1 = await getState(page);
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
const frozen2 = await getState(page);
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
});
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
const b = await mintPlayerB(browser);
bCtx = b.ctx;
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
await waitForGroundedFleet(page);
const sA = await getState(page); // A's (local session's) view of the shared world
const sB = await getState(b.page); // B's view — same world…
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
const starter = sA.planes.find((p) => p.id === STARTER)!;
expect(starter.ownerId).toBe('local');
expect(starter.ownerName).toBe(LOCAL_USERNAME);
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
expect(bSummary, 'the players summary lists player B').toBeDefined();
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
expect(flyForeign.status()).toBe(403);
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
expect(sellForeign.status()).toBe(403);
// …and B cannot act on A's.
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA.status()).toBe(403);
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
// so nothing else can touch either economy between reads.
const cA = sA.account.cash;
const cB = sB.account.cash;
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
expect(grant.status()).toBe(200);
const granted = ((await grant.json()) as StateBody).account.cash;
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
const sBAfter = await getState(b.page);
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
for (const s of [sA, sBAfter]) {
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
}
});
+5
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // 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). // The panes render from the shell's single /api/state poll (lib/useGameState.ts).
+205
View File
@@ -0,0 +1,205 @@
import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
import { haversineKm } from '../lib/geo';
import { legPhases } from '../lib/sim/movement';
import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog';
// v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and
// assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate)
// and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The
// phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork:
// each observation happens inside a window computed from departureSimTime + clockMs.
test.beforeEach(async ({ page }) => { await authenticate(page); });
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses
const STARTER = 'plane-starter';
// Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long
// enough for the fit + click + popup pipeline, short enough to keep the test fast.
const OBS = 30;
// Fast-forward through the cruise between the two observations (pure arrival wait).
const MID = 1_000;
// Self-heal and final arrival waits — no mid-leg observation at this rate.
const FAST = 50_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface StateBody {
simState: { clockMs: number };
planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>;
}
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing.
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 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);
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
async function waitForAirportCount(page: Page): Promise<void> {
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;
}).toBeGreaterThan(0);
}
// Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left
// it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second.
async function ensureGroundedAt(page: Page, icao: string): Promise<void> {
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
if (p.status === 'available' && p.locationIcao === icao) return 'ok';
if (p.status === 'available') {
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } });
return `flying home from ${p.locationIcao}`;
}
return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly)
}, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok');
}
// Frame both endpoints so the marker is rendered wherever it holds on the leg.
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];
});
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);
}
// Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state
// fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames —
// used right after dispatch, when the last rendered frame may predate it.
async function clickPlane(page: Page, flyingOnly: boolean): Promise<void> {
const pt = await page.evaluate(async ({ onlyFlying, starterId }: { onlyFlying: boolean; starterId: string }) => {
const m = (window as any).__flightMap;
if (!m) return null;
const deadline = Date.now() + 10_000;
for (;;) {
let feats: Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }> = [];
try {
feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] });
} catch {
// map not ready yet — keep polling
}
const f = feats.find((x) => x.properties?.id === starterId && (!onlyFlying || x.properties?.flying === true));
if (f && f.geometry) {
const projected = m.project(f.geometry.coordinates);
const rect = m.getContainer().getBoundingClientRect();
return { x: rect.left + projected.x, y: rect.top + projected.y };
}
if (Date.now() >= deadline) return null;
await new Promise((r) => setTimeout(r, 100));
}
}, { onlyFlying: flyingOnly, starterId: STARTER });
expect(pt, 'plane marker not rendered').not.toBeNull();
// Popups are created per click and never auto-close — remove any stale ones so the testids below
// stay unique.
await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove()));
await page.mouse.click(pt!.x, pt!.y);
const popup = page.getByTestId('plane-popup');
await expect(popup).toBeVisible({ timeout: 5_000 });
}
test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => {
await page.goto('/');
await waitForAirportCount(page);
// Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left.
await setScale(page, FAST);
await ensureGroundedAt(page, HUB);
// The exact phase boundaries for this leg — same composition the engine uses (v1.12.0).
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((x) => x.properties.icao === HUB)!;
const dest = fc.features.find((x) => x.properties.icao === DEST)!;
const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon);
const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST);
// --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. ---
await setScale(page, OBS);
const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!;
await fitToAirports(page, [HUB, DEST]);
await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch
await expect(page.getByTestId('plane-status')).toHaveText('flying');
await expect(page.getByTestId('plane-phase')).toContainText('taking off');
// --- Landing: fast-forward through the cruise to just before the landing window, then drop back
// to OBS so the hold at the destination gate is observable. ---
const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs;
await setScale(page, MID); // fast-forward through takeoff hold + cruise (pure arrival wait)
await expect.poll(async () => {
const body = await getState(page);
const p = body.planes.find((x) => x.id === STARTER)!;
if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins
}, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true);
await setScale(page, OBS);
// Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and
// before arrival (landingMs ≥ BASE_PHASE_MS > the offset below).
const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000);
await expect.poll(async () => {
const body = await getState(page);
if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
return body.simState.clockMs >= landingTarget;
}, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true);
await clickPlane(page, true);
await expect(page.getByTestId('plane-status')).toHaveText('flying');
await expect(page.getByTestId('plane-phase')).toContainText('landing');
// --- Arrival: fast-forward until the engine flips it to available at DEST. ---
await setScale(page, FAST);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true);
// Leave the onboarding state for the other specs: starter grounded back at HUB.
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true);
});
+31 -15
View File
@@ -7,6 +7,11 @@ import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement'; import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // 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 // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and // while flying, ISS-053), and
@@ -15,6 +20,12 @@ import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/move
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale —
// a lagged tick can only finish the leg sooner, never skip an observed state.
const FAST = 10_000;
// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's
// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain.
const CHAIN = 4_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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 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 DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
@@ -280,14 +291,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE); // Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
@@ -361,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
// to arrival, then fly home. // to arrival, then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200); expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival waits — FAST is safe and ~10× quicker than SCALE here.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
@@ -452,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
expect(flyRes.status()).toBe(200); expect(flyRes.status()).toBe(200);
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued // Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a // return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. // CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
await setScale(page, SCALE); await setScale(page, CHAIN);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === HUB; return !!p && p.status === 'flying' && p.destIcao === HUB;
@@ -561,8 +574,10 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); 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; const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up… // Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must
await setScale(page, SCALE); // stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the
// snapshot window below is sized for this rate.
await setScale(page, 2_000);
await expect.poll(async () => { await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE; return !!x && x.status === 'flying' && x.destIcao === QUEUE;
@@ -572,9 +587,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below. // Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel; const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure… expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
// 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn; // > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind
// a wrong top-up level (≤ required2, or none at all) still lands far below this bound. // load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
expect(fuel).toBeGreaterThan(1.3 * required2); expect(fuel).toBeGreaterThan(required2);
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
// Let leg 2 finish so the next test starts from a known airport. // Let leg 2 finish so the next test starts from a known airport.
@@ -598,7 +613,8 @@ test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200); expect(cancel.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST; return !!x && x.status === 'available' && x.locationIcao === DEST;
+38 -10
View File
@@ -1,6 +1,11 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. // v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // 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) // been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
@@ -9,13 +14,30 @@ import { expect, test } from '@playwright/test';
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now. // 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. // Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time const FAST = 10_000; // pure arrival waits (no mid-leg observation) — cleanup legs land in ~1 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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) 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 // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
// away at the initial zoom, so a click on its projected center cannot hit another circle. // shows both Africa + Europe with overlapping markers (LFRS's nearest neighbour LFRB Brest is only
// ~13–19 px away at z2), so framePopupAirport below frames it at z5 first — its nearest marker is then
// ~104 px away and a click on the projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
async function framePopupAirport(page: Page): Promise<void> {
await page.evaluate((a) => (window as any).__flightMap.jumpTo({ center: [a.lon, a.lat], zoom: 5 }), POPUP_AIRPORT);
// Wait until the marker is actually rendered at the new camera before clicking it. The airport is
// passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope values don't exist.
await expect.poll(async () =>
page.evaluate((a) => {
const map = (window as any).__flightMap;
const p = map.project([a.lon, a.lat]);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === a.icao);
}, POPUP_AIRPORT),
{ timeout: 15_000 },
).toBe(true);
}
interface StateBody { interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>; planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
} }
@@ -71,8 +93,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page); let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!; let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) { if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). // Fast-forward any leftover leg / repositioning flight (~2 s real time worst case at FAST).
await setScale(page, SCALE); await setScale(page, FAST);
} }
if (plane.status === 'flying') { if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
@@ -82,8 +104,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!; plane = s.planes.find((p) => p.id === 'plane-starter')!;
} }
if (plane.locationIcao !== HUB) { if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards. // Make sure the world rate is back at FAST so the return leg fast-forwards.
await setScale(page, SCALE); await setScale(page, FAST);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); 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) 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 === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
@@ -100,7 +122,7 @@ test.afterEach(async ({ page }) => {
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('left pane Relocate section relocates a grounded plane', async ({ page }) => { test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -132,7 +154,7 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
}); });
test('airport popup offers Relocate options for grounded planes', async ({ page }) => { test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one leg to LFRS
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -161,6 +183,9 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
{ timeout: 15_000 }, { timeout: 15_000 },
).toBe(true); ).toBe(true);
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
await framePopupAirport(page);
// Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is
// passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist. // passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist.
const pt = await page.evaluate(({ lon, lat }) => { const pt = await page.evaluate(({ lon, lat }) => {
@@ -196,7 +221,7 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
}); });
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => { 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 test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg, fast-forwarded in cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -241,6 +266,9 @@ test('airport popup Relocate queues a leg for a flying plane', async ({ page })
{ timeout: 15_000 }, { timeout: 15_000 },
).toBe(true); ).toBe(true);
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
await framePopupAirport(page);
const pt = await page.evaluate(({ lon, lat }) => { const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([lon, lat]); const p = map.project([lon, lat]);
+58 -63
View File
@@ -9,6 +9,11 @@ import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/mo
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; 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'; import { STARTING_REPUTATION } from '../lib/game/seed';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination + // (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
@@ -35,10 +40,12 @@ interface PlaneState {
interface PackageState { interface PackageState {
id: string; id: string;
originIcao: string; // v0.65.0 — pool visibility assertions need the origin
destIcao: string;
originName: string; originName: string;
destName: string; destName: string;
reward: number; reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B) status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null; planeId: string | null;
createdSimTime: number; createdSimTime: number;
deadlineSimTime: number; deadlineSimTime: number;
@@ -147,39 +154,35 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
return (await created.json()) as PackageState; return (await created.json()) as PackageState;
} }
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. // Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. The end
// state is verified by ONE poll on FRESH snapshots (v1.12.0): legs now include takeoff/landing holds,
// so a leftover leg can land at its destination after an earlier snapshot was taken — deciding from a
// stale pre-flight snapshot let tests load cargo onto a plane that wasn't actually at the hub (409).
async function flyHomeViaApi(page: Page): Promise<void> { 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 // 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 the steps below act on the starter, so the // active repair gate from an interrupted run before the steps below act on the starter, so the
// farthest-flyable picker and fuel pins never see a worn or broken plane. // farthest-flyable picker and fuel pins never see a worn or broken plane.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); // ISS-127-style transient: test hooks intermittently 500 under dind load — retry briefly instead of failing.
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here for (let attempt = 1; ; attempt++) {
const starter = s0.planes.find((p) => p.id === 'plane-starter')!; const healthRes = await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } });
if (starter.status === 'flying' || starter.locationIcao !== HUB) { if (healthRes.status() === 200) break;
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). if (attempt >= 3) throw new Error(`/api/test/set-health returned ${healthRes.status()} after 3 attempts`);
await setScale(page, SCALE); await page.waitForTimeout(500);
} }
if (starter.status === 'flying') { // Fast-forward any leftover leg / repositioning flight (a few seconds real time worst case at 1000×).
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);
}
if (starter.locationIcao !== HUB) {
// 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); await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
if (p.status === 'available' && p.locationIcao === HUB) return 'ok';
if (p.status === 'available') {
// 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. Fail loud with the server's reason otherwise.
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); 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) if (flyHome.status() !== 200) throw new Error(`reposition home failed (${flyHome.status()}): ${await flyHome.text()}`);
await expect return `flying home from ${p.locationIcao}`;
.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);
} }
return p.status; // flying or repairing — keep waiting (SCALE makes both finish quickly)
}, { timeout: 240_000, message: 'waiting for plane-starter to be grounded at the hub' }).toBe('ok');
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup). // Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page); const s1 = await getState(page);
for (const pkg of s1.packages) { for (const pkg of s1.packages) {
@@ -248,15 +251,39 @@ test('a priority package shows its special badge on the open-package row', async
await expect(badge).toHaveText('Priority'); 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 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); 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('/'); await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
await expect(row).toBeVisible({ timeout: 15_000 }); });
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
test('jobs at an airport with no grounded plane stay hidden while a plane is still en route (ISS-159)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter grounded at EDDF
const nearest = await nearestToHub(page);
// Dispatch the only plane toward `nearest` — while airborne its DESTINATION joins the pool (v0.65.0), so a job there
// reaches /api/state, but no GROUNDED plane sits at that airport yet: ISS-159 hides it until landing.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200);
const pkg = await createPackage(page, nearest.icao, HUB);
// The server keeps the job (its origin is in the pool)…
await expect.poll(async () => (await getState(page)).packages.some((p) => p.id === pkg.id), { timeout: 15_000 }).toBe(true);
await page.goto('/');
// …but no row renders while the plane is still en route — no grounded plane at its origin. No landing wait needed;
// the next test's flyHomeViaApi self-heal finishes this leg.
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 }) => { test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
@@ -441,38 +468,6 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
}); });
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
await page.goto('/');
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 });
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
await ignoreBtn.click();
// Server truth: the package is 'ignored'…
await expect
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
timeout: 15_000,
message: 'waiting for the ignored status in /api/state',
})
.toBe('ignored');
// …and the row is gone from the open list (the section filters status==='open').
await expect(row).not.toBeVisible({ timeout: 15_000 });
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(again.status()).toBe(409);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => { test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
+80 -43
View File
@@ -1,4 +1,13 @@
import { test, expect, type Page } from '@playwright/test'; 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';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully // 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 // grounded fleet freezes the clock, background-tick advance at a raised rate while something is
@@ -10,8 +19,6 @@ const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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 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;
const STARTER = 'plane-starter'; const STARTER = 'plane-starter';
interface StateBody { interface StateBody {
@@ -82,6 +89,55 @@ test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) =
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0); expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
}); });
test('a grounded fleet never starves: draining 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: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
// only the lightest band). 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 around
// 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). Expired slots carry no refill cooldown, so the
// board comes back on the very next tick.
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/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
}
}
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
// pool right away — within ~15 s of real time, with 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 }) => { test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
await activateWorld(page); await activateWorld(page);
try { try {
@@ -101,69 +157,50 @@ test('timeScale 1000 advances the clock by roughly real-time × 1000 between pol
} }
}); });
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => { test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page); const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open'); const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000. // 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 } }); const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200); 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.id).toBe(victim!.id);
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
await setScale(page, SCALE); // Expired jobs leave the market…
let after: StateBody | undefined;
let expiredBody: StateBody | undefined;
await expect await expect
.poll(async () => { .poll(async () => {
const body = await getState(page); const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body; if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!expiredBody; return !!after;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' }) }, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
.toBe(true); .toBe(true);
// …and the spawner refills the pool back up — never below its target.
const after = expiredBody!; expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired'); expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
// 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);
} finally {
await deactivateWorld(page);
}
}); });
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => { test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const created = await page.request.post('/api/test/expire-package'); const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201); expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string }; 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 setScale(page, SCALE); let after: StateBody | undefined;
let expiredBody: StateBody | undefined;
await expect await expect
.poll(async () => { .poll(async () => {
const body = await getState(page); const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body; if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!expiredBody; return !!after;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' }) }, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
.toBe(true); .toBe(true);
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
} finally {
await deactivateWorld(page);
}
}); });
test('POST /api/test/set-timescale validates its input', async ({ page }) => { test('POST /api/test/set-timescale validates its input', async ({ page }) => {
+5
View File
@@ -2,6 +2,11 @@ import { execSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on // Repo root — docker-compose.yml lives here; `docker compose restart` runs on
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one. // isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
+5
View File
@@ -1,6 +1,11 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the // v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
// right pane, in addition to the inline pane error) and game events appear as info toasts via // right pane, in addition to the inline pane error) and game events appear as info toasts via
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in // snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
+20 -5
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // 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 // greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
@@ -18,6 +23,7 @@ import { windBurnFactor } from '../lib/sim/movement';
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after. // guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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 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 STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
@@ -38,7 +44,7 @@ interface PackageState {
id: string; id: string;
originIcao: string; originIcao: string;
destIcao: string; destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null; planeId: string | null;
} }
@@ -344,7 +350,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
}); });
test('two planes fly in parallel and both deliver their packages', 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 test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000×
const secondId = await buySecondPlaneViaUi(page); const secondId = await buySecondPlaneViaUi(page);
// Full tanks for both — the starter's may be partial from an earlier run. // Full tanks for both — the starter's may be partial from an earlier run.
@@ -374,8 +380,9 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying'); expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao); expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
// Fast-forward to the FIRST landing (the short hop)… // Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a
await setScale(page, SCALE); // lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate.
await setScale(page, FAST);
await expect await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), { .poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000, timeout: 240_000,
@@ -398,9 +405,17 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook). // The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
await setFuelFull(page, secondId); await setFuelFull(page, secondId);
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs. // Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs
// dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the
// range-edge return leg at SCALE (~24 s).
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); 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); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 120_000,
message: 'waiting for both planes to land back at the hub',
})
.toBe(true);
}); });
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI — // ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
+17
View File
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports'; import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports';
import { haversineKm } from './geo';
const HEADER = 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"'; '"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"';
@@ -191,4 +192,20 @@ describe('isRemote (v0.61.0 — ISS-097 special remote airports)', () => {
expect(isRemote([...large, self], self.icao), `${self.icao} (${r.name}) must be remote`).toBe(true); expect(isRemote([...large, self], self.icao), `${self.icao} (${r.name}) must be remote`).toBe(true);
} }
}); });
it('committed dataset sparsity rule: every pair of non-large rows is at least REMOTE_RADIUS_KM apart', async () => {
const text = await fs.readFile(path.join(process.cwd(), 'data', 'airports_world.csv'), 'utf8');
const rows = filterWorldAirports(parseCsv(text));
// v0.68.0 (ISS-144): specials are greedily thinned to one per ~REMOTE_RADIUS_KM region, so no
// two non-large rows may sit closer than the radius. n≈dozens makes O(n²) trivial.
const nonLarge = rows.filter((r) => r.type !== 'large_airport').map((r) => ({ icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg }));
expect(nonLarge.length).toBeGreaterThan(0);
for (let i = 0; i < nonLarge.length; i++) {
for (let j = i + 1; j < nonLarge.length; j++) {
const d = haversineKm(nonLarge[i].lat, nonLarge[i].lon, nonLarge[j].lat, nonLarge[j].lon);
expect(d, `${nonLarge[i].icao} ↔ ${nonLarge[j].icao}: ${d.toFixed(1)} km < ${REMOTE_RADIUS_KM}`).toBeGreaterThanOrEqual(REMOTE_RADIUS_KM);
}
}
});
}); });
+87
View File
@@ -0,0 +1,87 @@
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 { createSession, hashPassword, requireAuth, verifyPassword } from './auth';
// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as
// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const reqWith = (sessionId?: string) =>
new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} });
describe('hashPassword / verifyPassword', () => {
it('round-trips a password through the stored digest', () => {
const hash = hashPassword('correct-horse-battery-staple');
expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's
expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true);
});
it('rejects a wrong password and malformed digests', () => {
const hash = hashPassword('some-password');
expect(verifyPassword('other-password', hash)).toBe(false);
expect(verifyPassword('x', 'not-a-digest')).toBe(false);
expect(verifyPassword('x', '')).toBe(false);
});
});
describe('requireAuth', () => {
it('401s when the header is missing or the session id is unknown', async () => {
for (const req of [reqWith(), reqWith('no-such-session')]) {
const guard = await requireAuth(req);
expect(guard.ok).toBe(false);
if (!guard.ok) {
expect(guard.response.status).toBe(401);
expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i);
}
}
});
it('401s an expired session', async () => {
const inst = await getGameDb();
inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]);
const guard = await requireAuth(reqWith('expired-sess'));
expect(guard.ok).toBe(false);
if (!guard.ok) expect(guard.response.status).toBe(401);
});
it('resolves the seeded admin session to the local player row (joined on username)', async () => {
const inst = await getGameDb();
const sessionId = await createSession(inst, 'admin-kamilkrzywda');
const guard = await requireAuth(reqWith(sessionId));
expect(guard.ok).toBe(true);
if (guard.ok) {
expect(guard.user.username).toBe('kamilkrzywda');
expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row
expect(guard.player.cash).toBe(20_000); // starter economy from the seed
}
});
it('500s when the account has no players row (broken data, not a client error)', async () => {
const inst = await getGameDb();
inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [
hashPassword('whatever-123'),
Date.now(),
]);
const sessionId = await createSession(inst, 'user-no-player');
const guard = await requireAuth(reqWith(sessionId));
expect(guard.ok).toBe(false);
if (!guard.ok) {
expect(guard.response.status).toBe(500);
expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i);
}
});
});
+122
View File
@@ -0,0 +1,122 @@
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import type { PlayerRow } from './db/schema';
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
// a single-origin dev tool surface, not a public auth system.
export const SESSION_HEADER = 'x-flight-session';
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
// Fixed digest compared against when the username is unknown, so every failed login runs one
// full scrypt and the timing shape stays uniform (no user-existence oracle).
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
export interface AuthUser {
id: string;
username: string;
admin: boolean;
}
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
// 32-byte hash). Malformed digests never match.
export function verifyPassword(password: string, storedHash: string): boolean {
const [saltHex, hashHex] = storedHash.split('$');
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
const computed = scryptSync(password, saltHex, 32).toString('hex');
try {
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
} catch {
return false;
}
}
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
// verifies through verifyPassword().
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
}
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
// doesn't silently log everyone out mid-session.
export async function createSession(inst: GameDb, userId: string): Promise<string> {
const id = randomUUID();
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
id,
userId,
Date.now() + SESSION_TTL_MS,
]);
await persist(inst);
return id;
}
// Resolve a session id to a live (non-expired) user, or null.
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
const res = inst.raw.exec(
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
[sessionId, Date.now()],
);
const row = res[0]?.values?.[0];
if (!row) return null;
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
}
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
export async function currentUser(req: Request): Promise<AuthUser | null> {
const sessionId = req.headers.get(SESSION_HEADER);
if (!sessionId) return null;
return resolveSession(await getGameDb(), sessionId);
}
export type AuthGuard =
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
| { ok: false; response: Response };
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
// (register and the mint hook always create both together — a miss means broken data).
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
const inst = await getGameDb();
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
const row = res[0]?.values?.[0];
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
return {
ok: true,
inst,
user,
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
};
}
export async function requireAuth(req: Request): Promise<AuthGuard> {
return finishAuth(await currentUser(req));
}
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
// ?session= query parameter (private infra; every other game route is header-only).
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
if (!sessionId) return finishAuth(null);
return finishAuth(await resolveSession(await getGameDb(), sessionId));
}
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
// No/expired session → 401, valid non-admin session → 403.
export async function requireAdmin(req: Request): Promise<AdminGuard> {
const user = await currentUser(req);
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
if (!user.admin) {
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
}
return { ok: true, inst: await getGameDb(), user };
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
// v1.1.0 (ISS-107) — client-side session handling shared by every authenticated fetcher, the
// sign-in screen and the admin panel. The server mints sessions at /api/login + /api/register;
// we only store the id in localStorage and echo it back as X-Flight-Session.
export const SESSION_STORAGE_KEY = 'flight.session';
export function storedSession(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null; // storage blocked (private mode) — treat as signed out
}
}
// Headers for an authenticated fetch. Empty object when no session is stored, so callers can
// spread it into their existing header objects without branching.
export function sessionHeaders(session: string | null = storedSession()): Record<string, string> {
return session ? { 'X-Flight-Session': session } : {};
}
export function storeSession(sessionId: string): void {
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
}
export function clearStoredSession(): void {
try {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
// storage blocked — nothing to clear
}
}
+180 -11
View File
@@ -2,12 +2,17 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import initSqlJs from 'sql.js'; import initSqlJs from 'sql.js';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CLOCK_MS } from '@/lib/game/seed'; import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
import { openGameDb, persist } from './client'; import { openGameDb, persist } from './client';
import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer';
import * as schema from './schema'; import * as schema from './schema';
// v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry.
const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' };
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. // The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
const LEGACY_DDL = ` const LEGACY_DDL = `
CREATE TABLE planes ( CREATE TABLE planes (
@@ -84,6 +89,40 @@ CREATE TABLE sim_state (
); );
`; `;
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
// file for the owner_id backfill migration test.
const PRE_OWNERSHIP_DDL = `
CREATE TABLE planes (
id TEXT PRIMARY KEY NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]',
health REAL NOT NULL DEFAULT 100,
repair_until_sim_time INTEGER
);
CREATE TABLE players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL,
reputation REAL NOT NULL
);
CREATE TABLE sim_state (
clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL,
paused INTEGER NOT NULL,
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1
);
`;
let dir: string; let dir: string;
beforeAll(async () => { beforeAll(async () => {
@@ -110,8 +149,13 @@ describe('openGameDb', () => {
expect(packages.length).toBeGreaterThanOrEqual(8); expect(packages.length).toBeGreaterThanOrEqual(8);
expect(packages.every((p) => p.status === 'open')).toBe(true); expect(packages.every((p) => p.status === 'open')).toBe(true);
const account = (await inst.db.select().from(schema.account))[0]; // v1.0.0 (ISS-105): the singleton economy is now a seeded local player row…
expect(account?.cash).toBeGreaterThan(0); expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// …and fresh DBs never create an `account` table.
const tables = new Set(
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate. // 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?.clockMs).toBe(STARTING_CLOCK_MS);
@@ -126,25 +170,25 @@ describe('openGameDb', () => {
const file = path.join(dir, 'reopen.db'); const file = path.join(dir, 'reopen.db');
const first = await openGameDb(file); const first = await openGameDb(file);
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort(); const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
const account1 = (await first.db.select().from(schema.account))[0]; const player1 = await getLocalPlayer(first);
// Simulate a process restart: brand-new instance over the same file. // Simulate a process restart: brand-new instance over the same file.
const second = await openGameDb(file); const second = await openGameDb(file);
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort(); const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
expect(packages2).toEqual(packages1); expect(packages2).toEqual(packages1);
expect((await second.db.select().from(schema.account))[0]).toEqual(account1); expect(await getLocalPlayer(second)).toEqual(player1);
}); });
it('persist() durably saves writes made after open', async () => { it('persist() durably saves writes made after open', async () => {
const file = path.join(dir, 'write.db'); const file = path.join(dir, 'write.db');
const inst = await openGameDb(file); const inst = await openGameDb(file);
await inst.db.update(schema.account).set({ cash: 12345 }); await updateLocalPlayerEconomy(inst, { cash: 12345 });
await persist(inst); await persist(inst);
// A fresh instance over the same file sees the update. // A fresh instance over the same file sees the update.
const reloaded = await openGameDb(file); const reloaded = await openGameDb(file);
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345); expect((await getLocalPlayer(reloaded)).cash).toBe(12345);
}); });
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
@@ -161,8 +205,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file); const inst = await openGameDb(file);
// Not re-seeded (account row exists)… // Not re-seeded (the legacy account row was migrated into the local player)…
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); expect((await getLocalPlayer(inst)).cash).toBe(20000);
// …but the new columns exist and are usable… // …but the new columns exist and are usable…
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane?.departureSimTime ?? null).toBeNull(); expect(plane?.departureSimTime ?? null).toBeNull();
@@ -194,8 +238,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file); const inst = await openGameDb(file);
// Wallet + sim clock survive byte-identical… // Wallet (migrated into the local player) + sim clock survive byte-identical…
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 }); expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605); expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)… // …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
@@ -222,6 +266,77 @@ describe('openGameDb', () => {
expect(cols.has('dest_icao')).toBe(true); expect(cols.has('dest_icao')).toBe(true);
}); });
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
// The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(),
// so it never verified; production DBs seeded by that version carry exactly this value.
const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
const file = path.join(dir, 'admin-seed.db');
const inst = await openGameDb(file);
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
// Simulate a process restart: brand-new instance over the same file.
const second = await openGameDb(file);
rows = second.raw.exec('SELECT COUNT(*) FROM users');
expect(Number(rows[0].values[0][0])).toBe(1);
});
it('seeds the admin into a legacy DB that predates the users table', async () => {
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(LEGACY_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
const file = path.join(dir, 'legacy-admin.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
// The game world is untouched by the seed (its account row migrated to the local player).
expect((await getLocalPlayer(inst)).cash).toBe(20000);
});
it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => {
// A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(`CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)`);
raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [
'admin-kamilkrzywda',
'kamilkrzywda',
LEGACY_HASH,
Date.now(),
]);
const file = path.join(dir, 'admin-rotate.db');
await fs.writeFile(file, Buffer.from(raw.export()));
// First open heals the row to the current digest…
const first = await openGameDb(file);
let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
// …and a second open is a no-op (idempotent rotation).
const second = await openGameDb(file);
rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
});
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => { it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
const file = path.join(dir, 'pre-v12-reopen.db'); const file = path.join(dir, 'pre-v12-reopen.db');
const SQL = await initSqlJs(); const SQL = await initSqlJs();
@@ -244,4 +359,58 @@ describe('openGameDb', () => {
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD'); expect(plane.destIcao).toBe('LEMD');
}); });
it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => {
// Minimal legacy file — just an `account` table; open() creates everything else from DDL.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`);
raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`);
const file = path.join(dir, 'pre-v1.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
let tables = new Set(
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
// Second open is a no-op (idempotent): the row survives unchanged, still exactly one player.
const second = await openGameDb(file);
expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
tables = new Set(
second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(PRE_OWNERSHIP_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
const file = path.join(dir, 'pre-ownership.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
// Every legacy plane is backfilled to the local player…
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners).toEqual(['local', 'local']);
// …and the world was NOT re-seeded (the players row already existed).
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
// Second open is a no-op: an owner change made after the migration survives a reopen.
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
await persist(inst);
const second = await openGameDb(file);
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners2).toEqual(['local', 'someone-else']);
});
}); });
+91 -7
View File
@@ -5,6 +5,7 @@ import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js'; import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { buildSeed, mulberry32 } from '@/lib/game/seed'; import { buildSeed, mulberry32 } from '@/lib/game/seed';
import { LOCAL_PLAYER } from './localPlayer';
import * as schema from './schema'; import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the // SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
@@ -22,6 +23,9 @@ export interface GameDb {
const DDL = ` const DDL = `
CREATE TABLE IF NOT EXISTS planes ( CREATE TABLE IF NOT EXISTS planes (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
owner_id TEXT NOT NULL,
model TEXT NOT NULL, model TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
fuel REAL NOT NULL, fuel REAL NOT NULL,
@@ -47,9 +51,16 @@ CREATE TABLE IF NOT EXISTS packages (
deadline_sim_time INTEGER NOT NULL, deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
plane_id TEXT, 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 ( -- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local
-- single-player row is seeded by seedEmpty() or migrated from account in open().
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL, cash REAL NOT NULL,
reputation REAL NOT NULL reputation REAL NOT NULL
); );
@@ -60,8 +71,46 @@ CREATE TABLE IF NOT EXISTS sim_state (
last_real_tick_ms INTEGER NOT NULL DEFAULT 0, last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1 last_selected_time_scale REAL NOT NULL DEFAULT 1
); );
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
`; `;
// v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the
// operator's head — never in code, tests or comments; login verifies against this string.
// v0.72.0: recomputed with exactly the same call as verifyPassword() — salt passed as a
// plain string, scryptSync(pw, saltHex, 32) — because the v0.71.0 digest used different
// parameters and real logins failed with 401.
const ADMIN_SEED_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
// v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so
// pre-existing production DBs gain exactly one admin row and never a duplicate.
function seedAdminUser(raw: RawSqlJsDatabase): void {
raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
[ADMIN_SEED_HASH, Date.now()],
);
// v0.72.0: v0.71.0 seeded an incompatible digest into production DBs and the INSERT above
// can't overwrite them (WHERE NOT EXISTS), so rotate any legacy row to the current digest
// at boot — a no-op once healed, durable on the next persist().
raw.run(
`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`,
[ADMIN_SEED_HASH, ADMIN_SEED_HASH],
);
}
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF // v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs // NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
// already have everything from DDL above and this is a no-op for them. // already have everything from DDL above and this is a no-op for them.
@@ -98,12 +147,27 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('repair_until_sim_time')) { if (!cols('planes').has('repair_until_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER'); 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 // 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. // 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')) { if (!cols('sim_state').has('last_selected_time_scale')) {
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1'); raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale'); raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
} }
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
// constant default, which SQLite applies to every existing row at ALTER time).
if (!cols('planes').has('owner_id')) {
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
}
} }
// v0.12.0 one-time migration: contracts world → packages world. Detected by the // v0.12.0 one-time migration: contracts world → packages world. Detected by the
@@ -133,6 +197,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
raw.exec('DROP TABLE contracts'); raw.exec('DROP TABLE contracts');
} }
// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop
// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have
// `account`, so this runs exactly once per legacy file and is a no-op on later opens.
function migrateAccountToPlayers(raw: RawSqlJsDatabase): void {
raw.run(
'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account',
[LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username],
);
raw.exec('DROP TABLE account');
}
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead // FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
// of the real ./db/flight.db. // of the real ./db/flight.db.
export function defaultDbPath(): string { export function defaultDbPath(): string {
@@ -150,6 +225,7 @@ async function open(file: string): Promise<GameDb> {
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL); // no-op for tables that already exist in an older file raw.exec(DDL); // no-op for tables that already exist in an older file
migrateColumns(raw); migrateColumns(raw);
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so // v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
// without this it would keep the dead contracts table forever. // without this it would keep the dead contracts table forever.
@@ -159,12 +235,18 @@ async function open(file: string): Promise<GameDb> {
if (tables.has('contracts')) { if (tables.has('contracts')) {
migrateContractsToPackages(raw); migrateContractsToPackages(raw);
} }
// v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a
// seeded local player and drop it. The players-empty guard makes this idempotent even if a file
// somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file.
const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0);
if (tables.has('account') && playerCount === 0) {
migrateAccountToPlayers(raw);
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First-run seed: only when the DB is empty (no account row yet). // First-run seed: only when the DB is empty (no player row yet).
const res = raw.exec('SELECT COUNT(*) FROM account'); if (playerCount === 0 && !tables.has('account')) {
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`); console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance); await seedEmpty(instance);
} }
@@ -194,9 +276,11 @@ async function seedEmpty(instance: GameDb): Promise<void> {
// SeedPlane.queue is a string[]; the column stores it as JSON text. // SeedPlane.queue is a string[]; the column stores it as JSON text.
const { queue, ...planeCols } = data.plane; const { queue, ...planeCols } = data.plane;
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) }); // v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
await instance.db.insert(schema.packages).values(data.packages); await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account); // v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
await instance.db.insert(schema.simState).values(data.simState); await instance.db.insert(schema.simState).values(data.simState);
await persist(instance); await persist(instance);
} }
+49
View File
@@ -0,0 +1,49 @@
import { eq, max } from 'drizzle-orm';
import type { GameDb } from './client';
import * as schema from './schema';
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
export async function getLocalPlayer(inst: GameDb): Promise<schema.PlayerRow> {
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, LOCAL_PLAYER.id));
if (!row) throw new Error('local player row missing');
return row;
}
export async function updateLocalPlayerEconomy(
inst: GameDb,
patch: { cash?: number; reputation?: number },
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
}
// v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and
// session-scoped snapshots thread the acting player's id through and read/write THAT row; the
// 'local' helpers above are reserved for world-level reads with no acting session.
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
if (!row) throw new Error(`player ${id} missing`);
return row;
}
export async function updatePlayerEconomy(
inst: GameDb,
id: string,
patch: { cash?: number; reputation?: number },
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
}
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
return row?.m ?? 0;
}
+41 -4
View File
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', { export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(), id: text('id').primaryKey().notNull(),
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
// onboarding stamp it explicitly on insert.
ownerId: text('owner_id').notNull(),
model: text('model').notNull(), model: text('model').notNull(),
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the // v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
// engine flips it back to 'available' on a tick. All status guards key off 'available'. // engine flips it back to 'available' on a tick. All status guards key off 'available'.
@@ -53,13 +57,26 @@ export const packages = sqliteTable('packages', {
deadlineSimTime: integer('deadline_sim_time').notNull(), deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded // 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free. // expired packages keep occupying their slot, deliver no reward, and abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
planeId: text('plane_id'), planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(), 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 (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', { // v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local
// single-player row (id 'local') is seeded on first run / migrated from `account` by open();
// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only.
export const players = sqliteTable('players', {
id: text('id').primaryKey().notNull(),
provider: text('provider').notNull(),
username: text('username').notNull(),
cash: real('cash').notNull(), cash: real('cash').notNull(),
reputation: real('reputation').notNull(), reputation: real('reputation').notNull(),
}); });
@@ -77,7 +94,27 @@ export const simState = sqliteTable('sim_state', {
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1), lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
}); });
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
export const users = sqliteTable('users', {
id: text('id').primaryKey().notNull(),
username: text('username').notNull().unique(),
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
passwordHash: text('password_hash').notNull(),
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at').notNull(),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey().notNull(),
userId: text('user_id').notNull(),
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
expiresAt: integer('expires_at').notNull(),
});
export type PlaneRow = typeof planes.$inferSelect; export type PlaneRow = typeof planes.$inferSelect;
export type PackageRow = typeof packages.$inferSelect; export type PackageRow = typeof packages.$inferSelect;
export type AccountRow = typeof account.$inferSelect; export type PlayerRow = typeof players.$inferSelect;
export type SimStateRow = typeof simState.$inferSelect; export type SimStateRow = typeof simState.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
+6 -9
View File
@@ -25,6 +25,8 @@ const basePlane = (id: string): PlaneState => ({
health: 100, // v0.39.0 (ISS-069) — full-health fixture default health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null, repairUntilSimTime: null,
queue: [], queue: [],
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
ownerName: 'kamilkrzywda',
}); });
const basePackage = (id: string): PackageState => ({ const basePackage = (id: string): PackageState => ({
@@ -38,17 +40,20 @@ const basePackage = (id: string): PackageState => ({
cargoCount: 2, cargoCount: 2,
weightKg: 600, weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures 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 distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0, deadlineSimTime: 0,
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
}); });
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 }, account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes: [basePlane('plane-starter')], planes: [basePlane('plane-starter')],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
packages: [], packages: [],
catalog: CATALOG, catalog: CATALOG,
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
@@ -63,7 +68,7 @@ describe('diffSnapshots', () => {
expect(diffSnapshots(prev, next)).toEqual([]); expect(diffSnapshots(prev, next)).toEqual([]);
}); });
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => { it('returns [] for the first snapshot (no baseline to diff against)', () => {
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] }); const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(null, next)).toEqual([]); expect(diffSnapshots(null, next)).toEqual([]);
}); });
@@ -112,14 +117,6 @@ describe('diffSnapshots', () => {
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]); expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
}); });
it('emits a new-package event for ids only in the next snapshot', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
]);
});
it('emits bought/sold events when the plane id set changes (with the model name)', () => { it('emits bought/sold events when the plane id set changes (with the model name)', () => {
const bought = diffSnapshots( const bought = diffSnapshots(
snapshot(), snapshot(),
+4 -9
View File
@@ -15,8 +15,7 @@ export interface GameEvent {
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model; const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] { export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a // No baseline yet (first snapshot after load) — nothing to diff against, so no events.
// "new package" event for every existing row would spam the stack on page load.
if (prev === null) return []; if (prev === null) return [];
const events: GameEvent[] = []; const events: GameEvent[] = [];
@@ -40,16 +39,12 @@ export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot):
} }
} }
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired // Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never // (open|loaded → expired). Ids that appear for the first time emit nothing.
// deleted, so an id appearing is always a genuinely new one).
const prevPackages = new Map(prev.packages.map((p) => [p.id, p])); const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) { for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id); const before = prevPackages.get(pkg.id);
if (!before) { if (!before) continue;
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (before.status === 'loaded' && pkg.status === 'delivered') { if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` }); events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') { } else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
+198 -147
View File
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client'; import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
@@ -21,14 +22,13 @@ import {
repRate, repRate,
SELL_FRACTION, SELL_FRACTION,
} from './catalog'; } from './catalog';
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed'; import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
import { import {
abandonPackage, abandonPackage,
buyPlane, buyPlane,
cancelQueuedLeg, cancelQueuedLeg,
flyPlane, flyPlane,
GameActionError, GameActionError,
ignorePackage,
loadPackage, loadPackage,
queueLeg, queueLeg,
repairPlane, repairPlane,
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
const inst = await freshDb('load-ok.db'); const inst = await freshDb('load-ok.db');
await insertOpenPackage(inst); await insertOpenPackage(inst);
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
expect(row.status).toBe('loaded'); expect(row.status).toBe('loaded');
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
const inst = await freshDb(`load-${status}.db`); const inst = await freshDb(`load-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status }); await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status)); await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
}, },
); );
it('rejects an unknown package id with 404', async () => { it('rejects an unknown package id with 404', async () => {
const inst = await freshDb('load-punknown.db'); const inst = await freshDb('load-punknown.db');
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404); await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('load-ghost.db'); const inst = await freshDb('load-ghost.db');
await insertOpenPackage(inst); await insertOpenPackage(inst);
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404); await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
}); });
it('rejects a flying plane with 409', async () => { it('rejects a flying plane with 409', async () => {
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
}); });
it('rejects a plane that is not at the origin with 409', async () => { it('rejects a plane that is not at the origin with 409', async () => {
@@ -137,7 +137,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst); // originates at EDDF await insertOpenPackage(inst); // originates at EDDF
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
}); });
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => { it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
@@ -149,7 +149,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk }); await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
} }
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
}); });
it('counts expired packages as occupying their payload too', async () => { it('counts expired packages as occupying their payload too', async () => {
@@ -160,7 +160,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half }); await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half }); await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
}); });
}); });
@@ -168,7 +168,7 @@ describe('flyPlane', () => {
it('sets flying + departure at current clock + dest_icao + origin coords', async () => { it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
const inst = await freshDb('fly-ok.db'); const inst = await freshDb('fly-ok.db');
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -188,38 +188,38 @@ describe('flyPlane', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409); await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('fly-ghost.db'); const inst = await freshDb('fly-ghost.db');
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404); await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
}); });
it('rejects an ICAO not in the airport dataset with 400', async () => { it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('fly-unknownicao.db'); const inst = await freshDb('fly-unknownicao.db');
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
}); });
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => { it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /broken \(health 0\)/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded… expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched… expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and no cash left the account
}); });
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => { it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // no 409 — worn is not broken
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -250,10 +250,10 @@ describe('flyPlane', () => {
const startFuel = Math.round(required / 2); // partial tank below the requirement const startFuel = Math.round(required / 2); // partial tank below the requirement
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses. // Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 }); await updateLocalPlayerEconomy(inst, { reputation: 30 });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
@@ -264,22 +264,22 @@ describe('flyPlane', () => {
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target… expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost expect(player.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
}); });
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => { it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => {
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
const required = await adjustedRequiredForHubToLemd(); const required = await adjustedRequiredForHubToLemd();
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched… expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and free
}); });
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => { it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
@@ -287,9 +287,9 @@ describe('flyPlane', () => {
const required = await adjustedRequiredForHubToLemd(); const required = await adjustedRequiredForHubToLemd();
const startFuel = Math.round(required / 2); const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 0 }); await updateLocalPlayerEconomy(inst, { cash: 0 });
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /insufficient funds/i);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded… expect(plane.status).toBe('available'); // still grounded…
@@ -301,17 +301,17 @@ describe('flyPlane', () => {
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable. // UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD', 'local'), 409, /insufficient fuel/i);
}); });
// --- Queue consumption on dispatch (v0.46.0, ISS-088) --- // --- 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 () => { 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'); const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -321,10 +321,10 @@ describe('flyPlane', () => {
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => { it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db'); const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -337,11 +337,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('appends legs to the queue in order and persists them as JSON', async () => { it('appends legs to the queue in order and persists them as JSON', async () => {
const inst = await freshDb('qleg-ok.db'); const inst = await freshDb('qleg-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
let plane = (await inst.db.select().from(schema.planes))[0]; let plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
plane = (await inst.db.select().from(schema.planes))[0]; plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
}); });
@@ -352,7 +352,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // still on its active leg… expect(plane.status).toBe('flying'); // still on its active leg…
@@ -362,19 +362,19 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qleg-ghost.db'); const inst = await freshDb('qleg-ghost.db');
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404); await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
}); });
it('rejects an ICAO not in the airport dataset with 400', async () => { it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('qleg-unknownicao.db'); const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
}); });
it('rejects a destination already in the queue with 409 (ISS-065)', async () => { it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db'); const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/); await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
@@ -386,7 +386,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/); await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
@@ -394,8 +394,8 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('still appends a different known airport with success (ISS-065 sanity)', async () => { it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db'); const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined(); await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
@@ -405,11 +405,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
it('removes the leg at the given index and keeps the rest in order', async () => { it('removes the leg at the given index and keeps the rest in order', async () => {
const inst = await freshDb('qcancel-ok.db'); const inst = await freshDb('qcancel-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await queueLeg(inst, 'plane-starter', 'EDDV'); await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg) await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']); expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
@@ -417,19 +417,19 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
it('rejects an out-of-range index with 400', async () => { it('rejects an out-of-range index with 400', async () => {
const inst = await freshDb('qcancel-oob.db'); const inst = await freshDb('qcancel-oob.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
}); });
it('rejects cancelling from an empty queue with 400', async () => { it('rejects cancelling from an empty queue with 400', async () => {
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qcancel-ghost.db'); const inst = await freshDb('qcancel-ghost.db');
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404); await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
}); });
}); });
@@ -442,7 +442,7 @@ describe('abandonPackage', () => {
const inst = await freshDb(name); const inst = await freshDb(name);
// Always insert OPEN and load it through the real action… // Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + 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'); await loadPackage(inst, 'p-ab', 'plane-starter', 'local');
// …then flip to expired in place (what the tick would do once the deadline passes). // …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') { if (status === 'expired') {
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab')); await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
@@ -452,23 +452,23 @@ describe('abandonPackage', () => {
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => { it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
const inst = await setupLoaded('ab-start.db'); const inst = await setupLoaded('ab-start.db');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100); expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned'); expect(row.status).toBe('abandoned');
expect(row.planeId).toBeNull(); // slot freed expect(row.planeId).toBeNull(); // slot freed
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6); expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
}); });
it('scales to exactly 37.5% at the midpoint of the window', async () => { it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db'); const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + 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'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100); expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
}); });
@@ -481,31 +481,31 @@ describe('abandonPackage', () => {
const inst = await setupLoaded(name); const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock }); await inst.db.update(schema.simState).set({ clockMs: clock });
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100); expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
} }
}); });
it('is free for an already-expired package', async () => { it('is free for an already-expired package', async () => {
const inst = await setupLoaded('ab-expired.db', 'expired'); const inst = await setupLoaded('ab-expired.db', 'expired');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(0); expect(penalty).toBe(0);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned'); expect(row.status).toBe('abandoned');
}); });
it('floors cash at €0 — no debt', async () => { it('floors cash at €0 — no debt', async () => {
const inst = await setupLoaded('ab-floor.db'); const inst = await setupLoaded('ab-floor.db');
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10 await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(150); expect(penalty).toBe(150);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0); expect((await getLocalPlayer(inst)).cash).toBe(0);
}); });
it('rejects abandoning while the plane is in flight with 409', async () => { it('rejects abandoning while the plane is in flight with 409', async () => {
@@ -514,62 +514,32 @@ describe('abandonPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i); await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
}); });
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => { it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
const inst = await freshDb(`ab-${status}.db`); const inst = await freshDb(`ab-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status }); await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status)); await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ab-unknown.db'); const inst = await freshDb('ab-unknown.db');
await expectActionError(abandonPackage(inst, 'nope'), 404); await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
});
});
describe('ignorePackage', () => {
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
const inst = await freshDb('ig-open.db');
await insertOpenPackage(inst, 'p-ig');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await ignorePackage(inst, 'p-ig');
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
expect(row.status).toBe('ignored');
expect(row.planeId).toBeNull(); // was never bound
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
});
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`ig-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
},
);
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ig-unknown.db');
await expectActionError(ignorePackage(inst, 'nope'), 404);
}); });
}); });
describe('buyPlane', () => { describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db'); const inst = await freshDb('buy-ok.db');
await inst.db.update(schema.account).set({ cash: 500_000 }); await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const before = (await inst.db.select().from(schema.planes)).length; const before = (await inst.db.select().from(schema.planes)).length;
const { planeId } = await buyPlane(inst, 'atr72'); const { planeId } = await buyPlane(inst, 'atr72', 'local');
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const planes = await inst.db.select().from(schema.planes); const planes = await inst.db.select().from(schema.planes);
expect(planes.length).toBe(before + 1); expect(planes.length).toBe(before + 1);
@@ -587,44 +557,44 @@ describe('buyPlane', () => {
it('rejects an unknown catalog model with 400', async () => { it('rejects an unknown catalog model with 400', async () => {
const inst = await freshDb('buy-unknown.db'); const inst = await freshDb('buy-unknown.db');
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/); await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
}); });
it('rejects the purchase when cash is below the price with 409', async () => { it('rejects the purchase when cash is below the price with 409', async () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
}); });
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => { it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
const inst = await freshDb('buy-rep.db'); const inst = await freshDb('buy-rep.db');
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k. // rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 }); await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
const before = (await inst.db.select().from(schema.planes)).length; const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72'); await buyPlane(inst, 'atr72', 'local');
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT); const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100; const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6); expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1); expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
}); });
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => { it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
const inst = await freshDb('buy-boundary.db'); const inst = await freshDb('buy-boundary.db');
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0. // rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 }); await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
const { planeId } = await buyPlane(inst, 'atr72'); const { planeId } = await buyPlane(inst, 'atr72', 'local');
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6); expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 }); await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i); await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
}); });
}); });
@@ -633,16 +603,16 @@ describe('sellPlane', () => {
const inst = await freshDb('sell-ok.db'); const inst = await freshDb('sell-ok.db');
// A second plane so the starter is not "the last remaining one". // A second plane so the starter is not "the last remaining one".
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { payout } = await sellPlane(inst, 'plane-extra'); const { payout } = await sellPlane(inst, 'plane-extra', 'local');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore + payout, 6); expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).not.toContain('plane-extra'); // row deleted expect(ids).not.toContain('plane-extra'); // row deleted
expect(ids).toContain('plane-starter'); expect(ids).toContain('plane-starter');
@@ -651,22 +621,22 @@ describe('sellPlane', () => {
it('rejects selling a flying plane with 409', async () => { it('rejects selling a flying plane with 409', async () => {
const inst = await freshDb('sell-flying.db'); const inst = await freshDb('sell-flying.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0, locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
}); });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
}); });
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => { it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
const inst = await freshDb('sell-cargo.db'); const inst = await freshDb('sell-cargo.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' }); await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
}); });
@@ -674,24 +644,24 @@ describe('sellPlane', () => {
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => { it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.db'); const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await queueLeg(inst, 'plane-extra', 'LEMD'); await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0]; 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 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 () => { it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('sell-unknown.db'); const inst = await freshDb('sell-unknown.db');
await expectActionError(sellPlane(inst, 'ghost'), 404); await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
}); });
}); });
@@ -699,31 +669,31 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => { it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
const inst = await freshDb('repair-ok.db'); const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter'); const { cost } = await repairPlane(inst, 'plane-starter', 'local');
expect(cost).toBe(REPAIR_COST); expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing'); expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually… expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
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) 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]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
}); });
it('is a free no-op when health is already full', async () => { it('is a free no-op when health is already full', async () => {
const inst = await freshDb('repair-full.db'); // seeded starter at 100 const inst = await freshDb('repair-full.db'); // seeded starter at 100
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter'); const { cost } = await repairPlane(inst, 'plane-starter', 'local');
expect(cost).toBe(0); expect(cost).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // not grounded… expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100); expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
}); });
it('rejects a flying plane with 409', async () => { it('rejects a flying plane with 409', async () => {
@@ -732,7 +702,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
}); });
it('rejects a plane that is already repairing with 409', async () => { it('rejects a plane that is already repairing with 409', async () => {
@@ -741,35 +711,116 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
}); });
it('blocks load/fly/sell while repairing (the existing status guards)', async () => { it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
const inst = await freshDb('repair-blocks.db'); const inst = await freshDb('repair-blocks.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await repairPlane(inst, 'plane-starter'); await repairPlane(inst, 'plane-starter', 'local');
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
// A second plane so the "last remaining" guard cannot fire before the status one. // A second plane so the "last remaining" guard cannot fire before the status one.
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/); await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
}); });
it('rejects the repair when cash is below REPAIR_COST with 409', async () => { it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
const inst = await freshDb('repair-poor.db'); const inst = await freshDb('repair-poor.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 }); await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('repair-unknown.db'); const inst = await freshDb('repair-unknown.db');
await expectActionError(repairPlane(inst, 'ghost'), 404); await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
});
});
describe('ownership (v1.2.0, ISS-109)', () => {
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
async function addPlayerB(inst: GameDb): Promise<void> {
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
}
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
const inst = await freshDb('own-403.db');
await addPlayerB(inst);
await insertOpenPackage(inst, 'p-own');
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
// Load as the owner first so abandon has a bound package to check…
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
// Nothing was mutated by the rejected attempts.
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(JSON.parse(plane.queue)).toEqual([]);
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
});
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
const inst = await freshDb('own-403-rev.db');
await addPlayerB(inst);
await inst.db.insert(schema.planes).values({
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
});
it('enforces the last-plane guard per owner — not on the world total', async () => {
const inst = await freshDb('own-lastplane.db');
await addPlayerB(inst);
// 'b' owns two planes; 'local' only the starter. World total: three.
for (const id of ['plane-b1', 'plane-b2']) {
await inst.db.insert(schema.planes).values({
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
}
// Selling local's only plane is still forbidden even though the world has three planes…
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
// …while b can sell one of their two.
const cashBefore = (await getPlayer(inst, 'b')).cash;
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
});
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
const inst = await freshDb('own-buy.db');
await addPlayerB(inst);
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
const { planeId } = await buyPlane(inst, 'atr72', 'b');
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
expect(bought.ownerId).toBe('b');
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
}); });
}); });
+63 -54
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client'; import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -33,6 +35,7 @@ import { HUB_ICAO } from './seed';
// 404 — unknown entity id // 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane // 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane) // status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
// 400 — bad input that isn't an existence problem (unknown airport/model) // 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error { export class GameActionError extends Error {
@@ -52,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon }; return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
} }
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
}
// --- Load (POST /api/packages/:id/load {planeId}) --- // --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's // open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages // origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until // 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned). // delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> { export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([ const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)), inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)), inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
@@ -70,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
} }
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
} }
@@ -90,8 +99,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`); 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) await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId }) .set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() })
.where(eq(schema.packages.id, packageId)); .where(eq(schema.packages.id, packageId));
await persist(inst); await persist(inst);
} }
@@ -107,10 +118,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s // doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it // with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched. // 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> { export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
} }
@@ -138,11 +150,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// any mutation, so a 409 leaves fuel and cash untouched. // any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5)); const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel); const liters = Math.max(0, target - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) { if (cost > 0 && player.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
} }
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel). // Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
if (requiredL > target) { if (requiredL > target) {
@@ -171,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
}) })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
if (cost > 0) { if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
} }
await persist(inst); await persist(inst);
} }
@@ -183,10 +195,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// active leg still starts via flyPlane, and queued legs chain on landing when fuel // active leg still starts via flyPlane, and queued legs chain on landing when fuel
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO // covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// in the airport dataset. // in the airport dataset.
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> { export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) { if (!fc.features.some((f) => f.properties.icao === destIcao)) {
@@ -213,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are // Remove a NOT-yet-started leg from the queue by index. Only queued legs are
// cancellable — the active in-flight leg (destIcao) is never affected. Validation: // cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// known plane, integer index within [0, queue.length). // known plane, integer index within [0, queue.length).
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> { export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const queue = parseIcaoQueue(plane.queue); const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) { if (index < 0 || index >= queue.length) {
@@ -233,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
// scaling with time pressure (15% at creation → 60% at the deadline, see // scaling with time pressure (15% at creation → 60% at the deadline, see
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never // lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// goes below €0. The slot is freed immediately. // goes below €0. The slot is freed immediately.
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> { export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0]; const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`); if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
@@ -245,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0] ? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
: undefined; : undefined;
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`); if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`); throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
} }
@@ -253,46 +268,29 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs); const penalty = abandonPenalty(pkg, sim.clockMs);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) }); await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId)); await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst); await persist(inst);
return { penalty }; return { penalty };
} }
// --- 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.
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];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
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 persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) --- // --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with // Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank // reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF). // at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> { export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model]; const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction. // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100; const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (account.cash < cost) { if (player.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
} }
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
@@ -302,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const planeId = randomUUID(); const planeId = randomUUID();
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: planeId, id: planeId,
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
model, model,
status: 'available', status: 'available',
fuel: type.maxFuel, // full tank on delivery fuel: type.maxFuel, // full tank on delivery
@@ -313,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null, departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069) health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
}); });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
await persist(inst); await persist(inst);
return { planeId }; return { planeId };
} }
@@ -322,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last // Sell a grounded plane for SELL_FRACTION of its catalog price. The last
// remaining plane can never be sold — the company needs at least one aircraft. // remaining plane can never be sold — the company needs at least one aircraft.
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> { export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
} }
@@ -344,17 +344,18 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first'); throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
} }
const all = await inst.db.select().from(schema.planes); // v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); // players' planes exist in the world, and selling one of two of yours is fine.
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
const type = PLANE_TYPES[plane.model]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100; const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId)); await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const account = (await inst.db.select().from(schema.account))[0]; const player = await getPlayer(inst, playerId);
if (!account) throw new Error('account row missing'); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
await persist(inst); await persist(inst);
return { payout }; return { payout };
} }
@@ -367,10 +368,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED // and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown // planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds. // plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
} }
@@ -379,17 +381,17 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the owner's own economy pays for the repair.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
if (account.cash < REPAIR_COST) { if (player.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
} }
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate. // Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await persist(inst); await persist(inst);
return { cost: REPAIR_COST }; return { cost: REPAIR_COST };
} }
@@ -400,10 +402,17 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
// game time — then respond with the FULL current state snapshot (same shape as // game time — then respond with the FULL current state snapshot (same shape as
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to // GET /api/state), so clients never need a follow-up poll. GameActionError maps to
// its own status + {error}; anything unexpected is a 500. // its own status + {error}; anything unexpected is a 500.
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> { // v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try { try {
await runAction(fn); await runAction((inst) => fn(inst, auth.player.id));
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`.
return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) { } catch (err) {
if (err instanceof GameActionError) { if (err instanceof GameActionError) {
return Response.json({ error: err.message }, { status: err.status }); return Response.json({ error: err.message }, { status: err.status });
+39 -26
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { travelDurationMs } from '@/lib/sim/movement'; import { legPhases } from '@/lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials'; import { SPECIALS } from './specials';
import { import {
@@ -10,12 +10,14 @@ import {
EXPIRY_BUFFER_MS, EXPIRY_BUFFER_MS,
JOB_BONUS_PER_REP, JOB_BONUS_PER_REP,
MAX_JOB_BONUS, MAX_JOB_BONUS,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage, generatePackage,
generatePoolPackage,
HUB_ICAO, HUB_ICAO,
MAX_JOB_LEG_KM, MAX_JOB_LEG_KM,
pickDestWithinCap, pickDestWithinCap,
POOL_BANDS,
poolBandOf,
POOL_TARGET_PER_AIRPORT,
mulberry32, mulberry32,
REWARD_PER_KG, REWARD_PER_KG,
REWARD_PER_KM, REWARD_PER_KM,
@@ -65,20 +67,26 @@ describe('buildSeed', () => {
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao); 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); const seed = seedWith(42);
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN); expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT);
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao)); const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
const byBand = [0, 0, 0, 0];
for (const c of seed.packages) { 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(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.status).toBe('open');
expect(c.planeId).toBeNull(); expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117) 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); expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
}); });
@@ -93,15 +101,16 @@ 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; const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
for (const seed of [seedWith(42), seedWith(7)]) { for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) { for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true); expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0); 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);
} }
}); });
@@ -171,8 +180,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
}); });
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => { 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 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 }); const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
expect(a).toEqual(b); expect(a).toEqual(b);
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 }); const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
@@ -186,13 +195,15 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).toBe(NOW + 10_000); 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 rngA = mulberry32(42);
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = []; const expected: string[] = [];
for (let i = 0; i < count; i++) { 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. // ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS }))); 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 }); const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected); expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
@@ -234,8 +245,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
}); });
it('is deterministic for a fixed RNG with forced endpoints', () => { 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 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' }); const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
expect(a).toEqual(b); expect(a).toEqual(b);
}); });
@@ -248,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)', () => { 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 // Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent. // endpoints consume no extra RNG draws when absent.
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); 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 }); const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW });
expect(a).toEqual(b); expect(a).toEqual(b);
}); });
}); });
@@ -266,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)); expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG… // 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); expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would. // …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 }); const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
@@ -323,8 +334,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
}); });
it('defaults to the starting reputation when omitted — byte-identical output', () => { 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 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 }); 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 expect(a).toEqual(b); // no bonus at the starting value
}); });
@@ -467,7 +478,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority) 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 o = airports.find((a) => a.icao === c.originIcao)!;
const d = airports.find((a) => a.icao === c.destIcao)!; 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; // v1.12.0: the floor includes the weather-scaled takeoff/landing phases (worst case for this pair).
const floorMs = legPhases(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH, o.icao, d.icao).totalMs + EXPIRY_BUFFER_MS;
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
} }
}); });
@@ -492,7 +504,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
} }
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS; // v1.12.0: phase-inclusive floor — same composition seed.ts uses for this pair.
const floorMs = legPhases(bestKm, FASTEST_CATALOG_SPEED_KMH, airports[bestI].icao, airports[bestJ].icao).totalMs + EXPIRY_BUFFER_MS;
for (const special of ['priority', null] as const) { for (const special of ['priority', null] as const) {
let clamped = 0; let clamped = 0;
for (let seed = 1; seed <= 50; seed++) { for (let seed = 1; seed <= 50; seed++) {
+119 -27
View File
@@ -1,5 +1,5 @@
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { travelDurationMs } from '@/lib/sim/movement'; import { legPhases } from '@/lib/sim/movement';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials'; import { pickSpecial, SPECIALS } from './specials';
@@ -63,6 +63,9 @@ export interface SeedPackage {
status: 'open'; status: 'open';
planeId: null; planeId: null;
createdSimTime: number; 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 { export interface SeedData {
@@ -86,12 +89,44 @@ export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.) // failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const PACKAGE_COUNT_MIN = 8; // v0.65.0: per-airport job pools replace the global market target (v0.7.0). Each airport where a
export const PACKAGE_COUNT_MAX = 12; // inclusive // 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 // Which pool band a package's weight falls into (v0.65.0) — the spawner counts open packages per
// count after expiries, so the job board never runs dry. // band and holds taken slots per band, so this must agree with POOL_BANDS' boundaries exactly.
export const MARKET_TARGET_OPEN_PACKAGES = 10; 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): 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 // 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 + // modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
@@ -165,6 +200,8 @@ export function rewardFor(km: number, weightKg: number): number {
// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at // 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 // 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. // 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: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -176,6 +213,7 @@ export function generatePackage(input: {
weightKg?: number; weightKg?: number;
special?: string | null; special?: string | null;
reputation?: number; reputation?: number;
createdRealMs?: number;
}): SeedPackage { }): SeedPackage {
const { airports, rng, nowMs } = input; const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports'); if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -231,6 +269,9 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER // 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. // 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())); 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 // Drawn deadline window — the existing draw, position unchanged; special scaling is applied
// below AFTER the appended special draw so the fixed order above holds. // 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; const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
@@ -243,9 +284,11 @@ export function generatePackage(input: {
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs // 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 // (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 // 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 // airports on Earth (max leg ~20000 km → floor ≈ 23.5 h incl. worst-case phases < the 36 h priority
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling. // ceiling), so no separate ceiling raise is needed. Forced offsets skip the floor, same as they skip
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS; // the special scaling. v1.12.0: the travel time includes the weather-scaled takeoff/landing phases —
// a package must be finishable even when both airports are in their worst (Fog) conditions.
const expiryFloorMs = legPhases(km, FASTEST_CATALOG_SPEED_KMH, origin.icao, dest.icao).totalMs + EXPIRY_BUFFER_MS;
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs); 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. // v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
@@ -257,16 +300,51 @@ export function generatePackage(input: {
destIcao: dest.icao, destIcao: dest.icao,
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)), reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType, cargoType,
cargoCount, cargoCount: finalCargoCount,
weightKg, weightKg,
special, special,
deadlineSimTime: Math.round(nowMs + deadlineMs), deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: nowMs, 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] // 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 // (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. // exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
@@ -284,19 +362,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0]; return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
} }
export function buildSeed(input: { // v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
airports: SeedAirport[]; // it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
rng: Rng; // caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
nowMs: number; export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const type = PLANE_TYPES[STARTER_MODEL]; const type = PLANE_TYPES[STARTER_MODEL];
const hub = pickHub(airports); const hub = pickHub(airports);
return {
const plane: SeedPlane = { id,
id: 'plane-starter',
model: STARTER_MODEL, model: STARTER_MODEL,
status: 'available', status: 'available',
fuel: type.maxFuel, // starts full fuel: type.maxFuel, // starts full
@@ -308,14 +381,33 @@ export function buildSeed(input: {
queue: [], // no queued legs on first run (v0.25.0) queue: [], // no queued legs on first run (v0.25.0)
health: 100, // full health on first run (v0.39.0, ISS-069) 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)); export function buildSeed(input: {
// Stable index-based ids for the seed set; runtime-generated entities must use airports: SeedAirport[];
// random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock rng: Rng;
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate. nowMs: number;
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const plane = buildStarterPlane(airports, 'plane-starter');
const hub = pickHub(airports);
// 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[] = []; const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) { let n = 1;
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS })); 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 { return {
+57 -3
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'; import fs from 'node:fs/promises';
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state'; import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { PLANE_TYPES } from './catalog';
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
import { getStateSnapshot, 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. // ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
@@ -14,11 +21,13 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
cargoCount: 3, cargoCount: 3,
weightKg: 900, weightKg: 900,
special: null, special: null,
expiresRealMs: status === 'open' ? 2_000_000 : null, // v0.65.0 — open jobs carry their wall-clock expiry
distanceKm: 2000, distanceKm: 2000,
deadlineSimTime: 1_000_000, deadlineSimTime: 1_000_000,
status, status,
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
}); });
const snapshot = (packages: PackageState[]): StateSnapshot => ({ const snapshot = (packages: PackageState[]): StateSnapshot => ({
@@ -28,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
sellFraction: 0.5, sellFraction: 0.5,
planes: [], planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
packages, packages,
}); });
@@ -39,7 +49,6 @@ describe('sseFrame', () => {
pkg('loaded', 'p-loaded'), pkg('loaded', 'p-loaded'),
pkg('abandoned', 'p-abandoned'), pkg('abandoned', 'p-abandoned'),
pkg('expired', 'p-expired'), pkg('expired', 'p-expired'),
pkg('ignored', 'p-ignored'),
]; ];
const frame = sseFrame(snapshot(all)); const frame = sseFrame(snapshot(all));
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']); expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
@@ -59,3 +68,48 @@ describe('sseFrame', () => {
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']); expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
}); });
}); });
// v1.6.0 (ISS-115) — the snapshot's players array: one row per player with cash/reputation and
// how many planes they own. Runs against a throwaway seeded DB via the app singleton, exactly
// like the /api/state route tests (fresh module registry per file ⇒ fresh singleton).
describe('getStateSnapshot players', () => {
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-snapshot-players-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'players.db');
await getGameDb(); // seeds the local player + starter plane
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
it('single-player regression guard — one row for the seeded local account', async () => {
const snap = await getStateSnapshot('local');
expect(snap.players).toEqual([
{ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 },
]);
});
it('two players — cash/reputation/planeCount per owner (v1.6.0, ISS-115)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy and two planes of its own — rows minted like /api/register does.
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: 5_000, reputation: 3 });
const type = PLANE_TYPES.atr72;
for (const id of ['plane-b-1', 'plane-b-2']) {
await inst.db.insert(schema.planes).values({
id, ownerId: 'b', model: 'atr72', status: 'available',
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
});
}
const snap = await getStateSnapshot('b'); // any session sees the whole world's players
expect(snap.players).toHaveLength(2);
const local = snap.players.find((p) => p.id === 'local')!;
expect(local).toEqual({ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 }); // starter still counted once…
const b = snap.players.find((p) => p.id === 'b')!;
expect(b).toEqual({ id: 'b', username: 'player-b', cash: 5_000, reputation: 3, planeCount: 2 }); // …and both of B's planes count for B.
});
});
+92 -12
View File
@@ -2,9 +2,11 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { getPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; 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). // Read-only game-state snapshot for client polling (GET /api/state).
@@ -35,6 +37,10 @@ export interface PlaneState {
health: number; health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair. // Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null; repairUntilSimTime: number | null;
// v1.4.0 (ISS-112): owner info — the snapshot carries ALL planes (shared world), so every row
// names its owner (players.id + username) and the panes can tell whose plane is whose.
ownerId: string;
ownerName: string;
} }
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against // Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
@@ -49,6 +55,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 { export interface PackageState {
id: string; id: string;
originIcao: string; originIcao: string;
@@ -65,10 +86,25 @@ export interface PackageState {
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored. // Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number; distanceKm: number;
deadlineSimTime: 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';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null; planeId: string | null;
createdSimTime: number; 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;
// v1.4.0 (ISS-112): carrier name — players.username of planes.owner_id via planeId, so an
// assigned package shows WHO is carrying it in the shared world; null when unassigned.
carrierName: string | null;
}
// v1.6.0 (ISS-115): per-player summary for the leaderboard panel — rides on every snapshot
// (GET /api/state + SSE frames), so no new endpoint is needed. planeCount = planes owned.
export interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
} }
export interface StateSnapshot { export interface StateSnapshot {
@@ -77,6 +113,9 @@ export interface StateSnapshot {
// only the clock itself is reported here. // only the clock itself is reported here.
simState: { clockMs: number }; simState: { clockMs: number };
planes: PlaneState[]; planes: PlaneState[];
// v1.6.0 (ISS-115): leaderboard data — one row per player, projected from the same players +
// planes reads as everything else in this snapshot (no extra query).
players: PlayerSummary[];
packages: PackageState[]; packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so // Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell previews without a separate endpoint. // the client can compute buy/sell previews without a separate endpoint.
@@ -87,7 +126,7 @@ export interface StateSnapshot {
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component // 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). // 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 // Terminal history (delivered/abandoned) 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. // 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 const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
@@ -95,20 +134,29 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) }; return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
} }
export async function getStateSnapshot(): Promise<StateSnapshot> { // v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
const { db } = await getGameDb(); // shape as before ({cash, reputation}), sourced per-player. v1.4.0 (ISS-112): this IS the shared
// projection for every surface that serves state — GET /api/state, action responses, the SSE
// connect frame and each tick's broadcast all call it with a player id (the broadcast projects
// once PER CONNECTED SESSION), so no two surfaces can drift. Planes are ALL planes in the world,
// each carrying owner info; packages carry carrierName when assigned to a plane.
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
const inst = await getGameDb();
const { db } = inst;
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm // NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and // 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding). // returns raw rows keyed by SQL column names (snake_case, no type decoding).
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([ const [player, simRows, planeRows, packageRows, playerRows] = await Promise.all([
db.select().from(schema.account), getPlayer(inst, playerId),
db.select().from(schema.simState), db.select().from(schema.simState),
db.select().from(schema.planes), db.select().from(schema.planes),
db.select().from(schema.packages), db.select().from(schema.packages),
// v1.4.0 (ISS-112): one players read serves both owner joins — plane rows via ownerId and
// package carrier names via planeId → planes.owner_id → players.username.
db.select().from(schema.players),
]); ]);
const accountRow = accountRows[0];
const simRow = simRows[0]; const simRow = simRows[0];
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row'); if (!simRow) throw new Error('game state missing sim_state row');
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
@@ -121,6 +169,20 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0; 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);
// v1.4.0 (ISS-112): owner joins — every plane row names its owner; a package's carrier is the
// username of ITS PLANE'S owner (planeId → planes.owner_id → players.username), null when unassigned.
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
const planeById = new Map(planeRows.map((p) => [p.id, p]));
// v1.6.0 (ISS-115): leaderboard — count owned planes per player from the rows already loaded above.
const planeCountByOwner = new Map<string, number>();
for (const p of planeRows) planeCountByOwner.set(p.ownerId, (planeCountByOwner.get(p.ownerId) ?? 0) + 1);
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its // 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. // departure values — live position and fuel are pure functions of the clock.
const livePlane = (p: schema.PlaneRow) => { const livePlane = (p: schema.PlaneRow) => {
@@ -130,7 +192,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const to = coordsByIcao.get(p.destIcao); const to = coordsByIcao.get(p.destIcao);
if (!type || !from || !to) return null; if (!type || !from || !to) return null;
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); // v1.12.0: piecewise cruise progress — holds at the origin during takeoff and at the
// destination during landing (weather-scaled phase durations from legPhases).
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this // Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed. // recomputes exactly the factor the engine used for the leg; no stored state needed.
@@ -146,7 +210,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
}; };
return { return {
account: { cash: accountRow.cash, reputation: accountRow.reputation }, account: { cash: player.cash, reputation: player.reputation },
simState: { clockMs: simRow.clockMs }, simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(), catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L, fuelPricePerL: FUEL_PRICE_PER_L,
@@ -169,9 +233,19 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
queue: parseIcaoQueue(p.queue), queue: parseIcaoQueue(p.queue),
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
repairUntilSimTime: p.repairUntilSimTime, repairUntilSimTime: p.repairUntilSimTime,
ownerId: p.ownerId, // v1.4.0 (ISS-112)
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
}; };
}), }),
packages: packageRows.map((p) => ({ // v1.6.0 (ISS-115): leaderboard rows — every player in the world with their economy + fleet size.
players: playerRows.map((pl) => ({
id: pl.id,
username: pl.username,
cash: pl.cash,
reputation: pl.reputation,
planeCount: planeCountByOwner.get(pl.id) ?? 0,
})),
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
id: p.id, id: p.id,
originIcao: p.originIcao, originIcao: p.originIcao,
originName: nameOf(p.originIcao), originName: nameOf(p.originIcao),
@@ -187,6 +261,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
createdSimTime: p.createdSimTime, createdSimTime: p.createdSimTime,
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
// v1.4.0 (ISS-112): carrier = the CARRYING PLANE's owner — unassigned packages stay null.
carrierName: (() => {
const plane = p.planeId ? planeById.get(p.planeId) : undefined;
return plane ? usernameByPlayerId.get(plane.ownerId) ?? plane.ownerId : null;
})(),
})), })),
}; };
} }
+29 -28
View File
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes: [], planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
packages: [], packages: [],
catalog: [ catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 }, { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
@@ -55,8 +56,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
@@ -83,7 +84,7 @@ describe('planesGeoJson', () => {
snapshot({ snapshot({
catalog: [], catalog: [],
planes: [ planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -95,7 +96,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
@@ -113,7 +114,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -127,7 +128,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -148,7 +149,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -161,7 +162,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -173,7 +174,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -185,7 +186,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -197,7 +198,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -217,7 +218,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -229,7 +230,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -246,7 +247,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -258,8 +259,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -273,7 +274,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest. // Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -288,7 +289,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('skips a grounded plane whose locationIcao is missing from the airport index', () => { it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -298,7 +299,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a repairing plane, even with a non-empty queue', () => { it('draws nothing for a repairing plane, even with a non-empty queue', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -308,7 +309,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a flying plane with an empty queue', () => { it('draws nothing for a flying plane with an empty queue', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -319,7 +320,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD. // Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -336,8 +337,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => { it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -347,9 +348,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => { it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] }, { id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -491,7 +492,7 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°. // leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson( 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'] }, { 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -529,7 +530,7 @@ describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (I
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
const snap = snapshot({ const snap = snapshot({
planes: [ 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: [] }, { 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}); });
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!); lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
+25 -2
View File
@@ -1,6 +1,7 @@
import type { PlaneState, StateSnapshot } from './game/state'; import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format'; import { formatFuel } from './format';
import { positionAt } from './sim/movement'; import { haversineKm } from './geo';
import { legPhaseAt, positionAt, type LegPhase } from './sim/movement';
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model // Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's // v0.12.0): one Point feature per plane, one LineString per FLYING plane's
@@ -14,9 +15,14 @@ export interface PlaneFeatureProps {
status: 'available' | 'flying' | 'repairing'; status: 'available' | 'flying' | 'repairing';
fuelText: string; // "1234 / 6300 L" fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded routeText: string; // "Origin → Destination" while flying, '' when grounded
// v1.12.0: 'taking off' | 'cruising' | 'landing' while flying (derived from the sim clock), '' when grounded.
phaseLabel: string;
flying: boolean; flying: boolean;
} }
// v1.12.0: display text for each timed leg segment (see legPhaseAt in lib/sim/movement.ts).
const PHASE_LABELS: Record<LegPhase, string> = { takeoff: 'taking off', cruise: 'cruising', landing: 'landing' };
export interface RouteFeatureProps { export interface RouteFeatureProps {
planeId: string; planeId: string;
} }
@@ -54,13 +60,29 @@ export function planesGeoJson(
if (!state) return EMPTY_POINTS; if (!state) return EMPTY_POINTS;
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback. // Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label])); const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
const speedByModel = new Map(state.catalog.map((e) => [e.model, e.speedKmh]));
const clockMs = state.simState.clockMs;
const features: MapPointFeature[] = state.planes.map((p) => { const features: MapPointFeature[] = state.planes.map((p) => {
const flying = p.status === 'flying'; const flying = p.status === 'flying';
let lon = p.lon; let lon = p.lon;
if (flying && p.destIcao) { // v1.12.0: the marker's phase label — same legPhases composition the engine uses, derived from
// departureSimTime + clockMs (both in the snapshot) so no extra state is needed.
let phaseLabel = '';
if (flying && p.destIcao && p.departureSimTime != null) {
const from = airportsByIcao.get(p.locationIcao); const from = airportsByIcao.get(p.locationIcao);
const to = airportsByIcao.get(p.destIcao); const to = airportsByIcao.get(p.destIcao);
const speedKmh = speedByModel.get(p.model);
if (from && to) lon = unwrapLon(p.lon, from.lon); if (from && to) lon = unwrapLon(p.lon, from.lon);
if (from && to && speedKmh !== undefined) {
phaseLabel = PHASE_LABELS[legPhaseAt(
p.departureSimTime,
haversineKm(from.lat, from.lon, to.lat, to.lon),
speedKmh,
p.locationIcao,
p.destIcao,
clockMs,
)];
}
} }
return { return {
type: 'Feature', type: 'Feature',
@@ -71,6 +93,7 @@ export function planesGeoJson(
status: p.status, status: p.status,
fuelText: formatFuel(p.fuel, p.maxFuel), fuelText: formatFuel(p.fuel, p.maxFuel),
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '', routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
phaseLabel,
flying, flying,
}, },
}; };
+73 -11
View File
@@ -3,19 +3,24 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => { it('documents exactly the eighteen public endpoints (4 read + 8 player actions + 4 auth + 2 admin CRUD)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/admin/tables', // v0.71.0 — admin panel
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/login', // v0.71.0 — admin panel auth
'/api/logout', // v0.71.0
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // 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/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
@@ -72,6 +77,7 @@ describe('OpenAPI spec', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
@@ -81,12 +87,14 @@ describe('OpenAPI spec', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState'); expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState'); expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState'); expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState'); expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry'); expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number'); expect(snap.fuelPricePerL.type).toBe('number');
@@ -123,6 +131,8 @@ describe('OpenAPI spec', () => {
'lon', 'lon',
'maxFuel', 'maxFuel',
'model', 'model',
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
'ownerName', // v1.4.0 (ISS-112) — owner's username
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started 'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing 'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'status', 'status',
@@ -132,11 +142,13 @@ describe('OpenAPI spec', () => {
expect(Object.keys(pkg).sort()).toEqual([ expect(Object.keys(pkg).sort()).toEqual([
'cargoCount', 'cargoCount',
'cargoType', 'cargoType',
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
'createdSimTime', 'createdSimTime',
'deadlineSimTime', 'deadlineSimTime',
'destIcao', 'destIcao',
'destName', 'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km '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', 'id',
'originIcao', 'originIcao',
'originName', 'originName',
@@ -152,7 +164,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs']); expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; 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) expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
}); });
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => { it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
@@ -179,16 +191,17 @@ describe('OpenAPI spec', () => {
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot'); expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
} }
// Error shapes per endpoint. // Error shapes per endpoint. v1.2.0 (ISS-109): every plane-targeted action also documents 403
// (the session player does not own the plane); buy-plane has no plane target, so it gains none.
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort(); const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '403', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0 expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '403', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400 expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['403', '404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none. // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
@@ -242,4 +255,53 @@ describe('OpenAPI spec', () => {
expect(sell.required).toEqual(['planeId']); expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string'); expect(sell.properties.planeId.type).toBe('string');
}); });
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
}
// Success shapes.
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
expect(tables.type).toBe('array');
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
// Request bodies + path parameter.
const bodyRef = (p: string, m: string) =>
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
?.schema.$ref;
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
}
// Request schema shapes.
const login = openApiSpec.components.schemas.LoginRequest;
expect(login.required).toEqual(['username', 'password']);
expect(login.properties.username.type).toBe('string');
expect(login.properties.password.type).toBe('string');
const sessionUser = openApiSpec.components.schemas.SessionUser;
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
expect(sessionUser.properties.admin.type).toBe('boolean');
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
expect(updateRow.required).toEqual(['rowid', 'values']);
expect(updateRow.properties.rowid.type).toBe('integer');
});
}); });
+419 -29
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'Flight Ops API', title: 'Flight Ops API',
description: description:
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.', 'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
// Sourced from package.json so it stays in sync with every version bump. // Sourced from package.json so it stays in sync with every version bump.
version: pkg.version, version: pkg.version,
}, },
@@ -110,6 +110,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown package id or plane id', description: 'Unknown package id or plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -136,6 +140,10 @@ export const openApiSpec = {
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)', description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
403: {
description: 'The session player does not own the bound plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown package id', description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -147,31 +155,6 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/packages/{id}/ignore': {
post: {
summary: 'Ignore an open job',
description:
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
operationId: 'ignorePackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Package not "open"',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/fly': { '/api/planes/{planeId}/fly': {
post: { post: {
summary: 'Fly a plane to an airport', summary: 'Fly a plane to an airport',
@@ -199,6 +182,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset', 'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -238,6 +225,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset', 'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -277,6 +268,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)', 'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -337,6 +332,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -362,6 +361,10 @@ export const openApiSpec = {
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)', description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -373,6 +376,274 @@ export const openApiSpec = {
}, },
}, },
}, },
// v1.1.0 — multiplayer step 3 (ISS-107): self-service account creation.
'/api/register': {
post: {
summary: 'Create an account',
description:
'Register a new local account (v1.1.0, ISS-107): username 3–20 chars of [a-z0-9_-] (case-insensitive input, stored lowercase), password at least 8 chars — else 400; 409 when the username is taken. Creates the users row (scrypt) AND a players row with the starter economy (the exact values a fresh world seeds: €20,000 cash, reputation 10). Mints a session immediately and returns it, so no second round-trip is needed.',
operationId: 'register',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/RegisterRequest' },
},
},
},
responses: {
200: {
description: 'Registered — new session id + the user (same shape as login)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
},
400: {
description: 'Invalid body (not an object, unknown field, bad username charset/length, or password shorter than 8 chars)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Username is already taken',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
'/api/login': {
post: {
summary: 'Log in',
description:
'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.',
operationId: 'login',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/LoginRequest' },
},
},
},
responses: {
200: {
description: 'Authenticated — new session id + the user',
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
},
400: {
description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
401: {
description: 'Unknown username or wrong password',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/logout': {
post: {
summary: 'Log out',
description:
'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.',
operationId: 'logout',
responses: {
200: {
description: 'Session deleted (or was already gone)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
},
401: {
description: 'Missing X-Flight-Session header',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/me': {
get: {
summary: 'Current user',
description:
'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.',
operationId: 'getMe',
responses: {
200: {
description: 'The authenticated user',
content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } },
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/admin/tables': {
get: {
summary: 'List database tables (admin)',
description:
'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.',
operationId: 'listAdminTables',
responses: {
200: {
description: 'The table names',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
},
},
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'Valid session but the user is not an admin',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/admin/tables/{name}/rows': {
get: {
summary: 'Read table rows (admin)',
description:
'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.',
operationId: 'listAdminRows',
parameters: [
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } },
],
responses: {
200: {
description: 'Column headers + rows (each row carries its __rowid)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } },
},
400: {
description: 'limit is not a positive integer',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'Valid session but the user is not an admin',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown table name',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
put: {
summary: 'Update a row (admin)',
description:
'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.',
operationId: 'updateAdminRow',
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdateRowRequest' },
},
},
},
responses: {
200: {
description: 'Updated',
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
},
400: {
description: 'Invalid body, non-integer rowid, unknown column, or empty values',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'Valid session but the user is not an admin',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown table name',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
post: {
summary: 'Insert a row (admin)',
description:
'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.',
operationId: 'insertAdminRow',
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/InsertRowRequest' },
},
},
},
responses: {
200: {
description: 'Inserted — the new rowid',
content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } },
},
400: {
description: 'Invalid body, unknown column, or empty values',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'Valid session but the user is not an admin',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown table name',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Database constraint violation (NOT NULL / UNIQUE)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
delete: {
summary: 'Delete a row (admin)',
description:
'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.',
operationId: 'deleteAdminRow',
parameters: [
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } },
],
responses: {
200: {
description: 'Deleted (or was already gone)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
},
400: {
description: 'Missing or non-integer rowid query parameter',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
401: {
description: 'Missing, unknown or expired session',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'Valid session but the user is not an admin',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown table name',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
}, },
components: { components: {
schemas: { schemas: {
@@ -436,6 +707,11 @@ export const openApiSpec = {
type: 'array', type: 'array',
items: { $ref: '#/components/schemas/PlaneState' }, items: { $ref: '#/components/schemas/PlaneState' },
}, },
players: {
type: 'array',
description: 'Leaderboard rows (v1.6.0, ISS-115) — one per player in the world.',
items: { $ref: '#/components/schemas/PlayerSummary' },
},
packages: { packages: {
type: 'array', type: 'array',
items: { $ref: '#/components/schemas/PackageState' }, items: { $ref: '#/components/schemas/PackageState' },
@@ -448,7 +724,7 @@ export const openApiSpec = {
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' }, fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' }, sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
}, },
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], required: ['account', 'simState', 'planes', 'players', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
}, },
CatalogEntry: { CatalogEntry: {
type: 'object', type: 'object',
@@ -479,6 +755,18 @@ export const openApiSpec = {
}, },
required: ['clockMs'], required: ['clockMs'],
}, },
PlayerSummary: {
type: 'object',
description: 'One leaderboard row (v1.6.0, ISS-115) — a player\'s economy plus how many planes they own.',
properties: {
id: { type: 'string' },
username: { type: 'string' },
cash: { type: 'number' },
reputation: { type: 'number' },
planeCount: { type: 'integer', description: 'Number of planes in the world owned by this player.' },
},
required: ['id', 'username', 'cash', 'reputation', 'planeCount'],
},
LoadPackageRequest: { LoadPackageRequest: {
type: 'object', type: 'object',
properties: { properties: {
@@ -521,6 +809,101 @@ export const openApiSpec = {
}, },
required: ['planeId'], required: ['planeId'],
}, },
// v1.1.0 — multiplayer step 3 (ISS-107).
RegisterRequest: {
type: 'object',
properties: {
username: { type: 'string', description: '3–20 chars of [a-z0-9_-]; input is lowercased before validation.' },
password: { type: 'string', description: 'Plaintext, at least 8 chars — stored as a scrypt digest server-side.' },
},
required: ['username', 'password'],
},
// v0.71.0 — admin panel auth + DB tooling schemas.
LoginRequest: {
type: 'object',
properties: {
username: { type: 'string', description: 'Account username (non-empty).' },
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
},
required: ['username', 'password'],
},
SessionUser: {
type: 'object',
properties: {
username: { type: 'string' },
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
},
required: ['username', 'admin'],
},
LoginResponse: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
user: { $ref: '#/components/schemas/SessionUser' },
},
required: ['sessionId', 'user'],
},
OkResponse: {
type: 'object',
properties: {
ok: { const: true },
},
required: ['ok'],
},
AdminTable: {
type: 'object',
properties: {
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
},
required: ['name'],
},
AdminRowsResponse: {
type: 'object',
properties: {
columns: {
type: 'array',
items: { type: 'string' },
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
},
rows: {
type: 'array',
items: { type: 'object', additionalProperties: true },
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
},
},
required: ['columns', 'rows'],
},
UpdateRowRequest: {
type: 'object',
properties: {
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
values: {
type: 'object',
additionalProperties: true,
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
},
},
required: ['rowid', 'values'],
},
InsertRowRequest: {
type: 'object',
properties: {
values: {
type: 'object',
additionalProperties: true,
description: 'Exactly the columns to set; others take their defaults or NULL.',
},
},
required: ['values'],
},
InsertRowResponse: {
type: 'object',
properties: {
ok: { const: true },
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
},
required: ['ok', 'rowid'],
},
Error: { Error: {
type: 'object', type: 'object',
properties: { properties: {
@@ -568,6 +951,8 @@ export const openApiSpec = {
description: description:
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.', 'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
}, },
ownerId: { type: 'string', description: 'Owner\'s players.id (v1.4.0, ISS-112) — the snapshot carries ALL planes in the shared world.' },
ownerName: { type: 'string', description: 'Owner\'s username (v1.4.0, ISS-112).' },
}, },
required: [ required: [
'id', 'id',
@@ -585,6 +970,8 @@ export const openApiSpec = {
'queue', 'queue',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100] 'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing 'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
'ownerName', // v1.4.0 (ISS-112) — owner's username
], ],
}, },
PackageState: { PackageState: {
@@ -604,10 +991,11 @@ export const openApiSpec = {
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick; // 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free. // loaded expired packages keep their slot, deliver no reward, abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] }, planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' }, 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.' },
carrierName: { type: ['string', 'null'], description: 'Carrier name (v1.4.0, ISS-112) — players.username of the carrying plane\'s owner via planeId → planes.owner_id; null when unassigned.' },
}, },
required: [ required: [
'id', 'id',
@@ -624,6 +1012,8 @@ export const openApiSpec = {
'status', 'status',
'planeId', 'planeId',
'createdSimTime', 'createdSimTime',
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
], ],
}, },
}, },
+353 -141
View File
@@ -5,22 +5,23 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed'; import { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
deliverPackages, deliverPackages,
expirePackages, expireLoadedPackages,
expireOpenPackages,
finishRepairs, finishRepairs,
MAX_TICK_ELAPSED_MS, MAX_TICK_ELAPSED_MS,
reputationGainFor, reputationGainFor,
shouldAdvanceTime, shouldAdvanceTime,
spawnCoveragePackages, spawnPoolPackages,
spawnOpenPackages,
tickOnce, tickOnce,
WORLD_TIME_SCALE, WORLD_TIME_SCALE,
} from './engine'; } from './engine';
@@ -140,41 +141,68 @@ describe('tickOnce — clock advance', () => {
}); });
}); });
describe('expirePackages', () => { describe('expireLoadedPackages (sim clock)', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db'); const inst = await freshDb('expiry-loaded.db');
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline // 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. // window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
const clock = STARTING_CLOCK_MS + 100_000; const clock = STARTING_CLOCK_MS + 100_000;
await inst.db.insert(schema.packages).values([ 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: STARTING_CLOCK_MS },
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ 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-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-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-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); const n = await expireLoadedPackages(inst, clock);
expect(n).toBe(2); // the open and the loaded one 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])); 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-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-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // 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 () => { 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: STARTING_CLOCK_MS + 50_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS,
});
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({ await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, 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, deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
createdRealMs: T0 - JOB_LIFETIME_MS,
}); });
expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive expect(await expireOpenPackages(inst, T0)).toBe(1); // <= is inclusive
}); });
}); });
@@ -201,20 +229,20 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
it('does nothing before the arrival time', async () => { it('does nothing before the arrival time', async () => {
const { inst, km, type } = await setupFlight('deliver-early.db'); const { inst, km, type } = await setupFlight('deliver-early.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO) - 1; // one ms before arrival
expect(await deliverPackages(inst, clock)).toBe(0); expect(await deliverPackages(inst, clock)).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(account.cash).toBe(accountBefore.cash); // no award before landing expect(player.cash).toBe(playerBefore.cash); // no award before landing
}); });
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db'); const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
// Three packages on the plane: one in-deadline for TO, one expired for TO, // Three packages on the plane: one in-deadline for TO, one expired for TO,
// one loaded for a DIFFERENT airport (stays bound for a later leg). // one loaded for a DIFFERENT airport (stays bound for a later leg).
@@ -224,7 +252,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]); ]);
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -247,56 +275,56 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo… expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo…
expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // ONLY p-pay paid out
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km)) * 100) / 100,
9, 9,
); );
// A later tick must not award again — the plane is no longer 'flying'. // A later tick must not award again — the plane is no longer 'flying'.
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0); expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
const accountAfter = (await inst.db.select().from(schema.account))[0]!; const playerAfter = (await getLocalPlayer(inst));
expect(accountAfter.cash).toBe(account.cash); expect(playerAfter.cash).toBe(player.cash);
expect(accountAfter.reputation).toBe(account.reputation); expect(playerAfter.reputation).toBe(player.reputation);
}); });
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => { it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
const { inst, km } = await setupFlight('deliver-multi.db'); const { inst, km } = await setupFlight('deliver-multi.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
await inst.db.insert(schema.packages).values([ await inst.db.insert(schema.packages).values([
{ id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]); ]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO);
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards expect(player.cash).toBeCloseTo(playerBefore.cash + 3_500, 9); // both rewards
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
9, 9,
); // rep per delivered package ); // rep per delivered package
}); });
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => { it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
const { inst, km } = await setupFlight('deliver-special.db'); const { inst, km } = await setupFlight('deliver-special.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
await inst.db.insert(schema.packages).values([ await inst.db.insert(schema.packages).values([
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]); ]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award. // Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9, 9,
); );
}); });
@@ -330,7 +358,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) }) .set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -354,8 +382,8 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted // Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
@@ -375,7 +403,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel); const liters = Math.max(0, target - landedFuel);
expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left
const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const rate = repRate(playerBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -385,23 +413,23 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order
expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost expect(player.cash).toBeCloseTo(playerBefore.cash - expectedCost, 9); // exactly the rep-discounted cost
}); });
it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => { it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => {
const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db'); const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db');
// Same empty-tank landing as above, but the account is broke — the top-up is unaffordable. // Same empty-tank landing as above, but the player is broke — the top-up is unaffordable.
const w = weatherFor(FROM); const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km); const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 0 }); await updateLocalPlayerEconomy(inst, { cash: 0 });
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -411,13 +439,13 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched
expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0); expect((await getLocalPlayer(inst)).cash).toBe(0);
}); });
it('grounds as before when the queue is empty', async () => { it('grounds as before when the queue is empty', async () => {
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]' const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // unchanged landing behavior… expect(plane.status).toBe('available'); // unchanged landing behavior…
@@ -445,7 +473,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['FGSL']) }) .set({ queue: JSON.stringify(['FGSL']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -462,13 +490,85 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) }) .set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; 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.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
expect(plane.destIcao).toBeNull(); expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
}); });
// --- Per-owner economy (v1.3.0, ISS-110) ---
it('pays cash + reputation to the plane OWNER, not the local player', async () => {
const { inst, km } = await setupFlight('deliver-owner.db');
// A second player owns the starter — distinct economy so any leak into 'local' is visible.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 5 });
await inst.db.update(schema.planes)
.set({ ownerId: 'player-b' })
.where(eq(schema.planes.id, 'plane-starter'));
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
const bBefore = (await getPlayer(inst, 'player-b'));
await inst.db.insert(schema.packages).values([
{ id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const bAfter = (await getPlayer(inst, 'player-b'));
expect(bAfter.cash).toBeCloseTo(bBefore.cash + 5_000, 9); // the owner was paid…
expect(bAfter.reputation).toBeCloseTo(Math.round((bBefore.reputation + reputationGainFor(km)) * 100) / 100, 9); // …with the distance-based rep gain
const localAfter = (await getPlayer(inst, LOCAL_PLAYER.id));
expect(localAfter.cash).toBe(localBefore.cash); // the local player is untouched…
expect(localAfter.reputation).toBe(localBefore.reputation); // …in both directions
});
it('debits the auto top-up from the plane OWNER, not the local player (ISS-082 × ISS-110)', async () => {
const { inst, from, to, km, type } = await setupFlight('chain-owner.db');
// Owner B funds the chain: distinct cash + rep so both the debit and the discount are attributable.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 50_000, reputation: 10 });
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD → lands empty; LEMD→EHAM needs a top-up.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes)
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']), ownerId: 'player-b' })
.where(eq(schema.planes.id, 'plane-starter'));
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
const bBefore = (await getPlayer(inst, 'player-b'));
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
// Expected cost from the SAME helpers the engine uses — computed with B's rep discount.
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
const wTo = weatherFor(TO);
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
);
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left
const rate = repRate(bBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // B's cash funded the chain…
expect(plane.destIcao).toBe('EHAM');
const bAfter = (await getPlayer(inst, 'player-b'));
expect(bAfter.cash).toBeCloseTo(bBefore.cash - expectedCost, 9); // …debited from B…
expect((await getPlayer(inst, LOCAL_PLAYER.id)).cash).toBe(localBefore.cash); // …not local
});
}); });
describe('wear on landed legs (v0.39.0, ISS-069)', () => { describe('wear on landed legs (v0.39.0, ISS-069)', () => {
@@ -494,7 +594,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => { it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
const { inst, km } = await setupFlight('wear-leg.db'); const { inst, km } = await setupFlight('wear-leg.db');
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085) expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
@@ -507,18 +607,19 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
.set({ health: WEAR_PER_KM * km / 2 }) .set({ health: WEAR_PER_KM * km / 2 })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
let plane = (await inst.db.select().from(schema.planes))[0]; let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // clamped — never negative… expect(plane.health).toBe(0); // clamped — never negative…
// …and a second leg keeps it pinned at 0. // …and a second leg keeps it pinned at 0.
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties; const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon); const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
const arr1 = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // leg 1's exact arrival (v1.12.0 phases)
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel }) .set({ status: 'flying', destIcao: 'EHAM', departureSimTime: arr1, fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(arr1, nextKm, type.speedKmh, TO, 'EHAM'))).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0]; plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // still clamped after the second leg expect(plane.health).toBe(0); // still clamped after the second leg
}); });
@@ -532,7 +633,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) }) .set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // broken by this landing's wear… expect(plane.health).toBe(0); // broken by this landing's wear…
@@ -641,11 +742,57 @@ describe('grounded fleet freezes the world clock (#135)', () => {
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
}); });
it('N-player fleet: one owner airborne keeps the clock running; all owners grounded freezes it (ISS-113)', async () => {
const inst = await freshDb('nplayer-clock.db');
// A second player with their own GROUNDED plane — the world now has two owners.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
await inst.db.insert(schema.planes).values({
id: 'plane-b', ownerId: 'player-b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
}); });
describe('spawnOpenPackages', () => { // A (local) takes off while B stays grounded → the world advances — one owner flying is enough…
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => { await makeWorldActive(inst);
const inst = await freshDb('spawn.db'); vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // …at the fixed world rate
// …and grounding A's plane (B was already grounded) freezes the whole world.
await inst.db.update(schema.planes)
.set({ status: 'available', destIcao: null, departureSimTime: null })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // a full second of real idleness with every owner grounded
const frozen = await tickOnce(inst);
expect(frozen.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // frozen — no advance…
expect(frozen.lastRealTickMs).toBe(T0 + 2_000); // …but the real-time anchor keeps tracking
});
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 the way wall-clock expiry would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
// empty", not "taken" — a real load 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: 'expired' }).where(eq(schema.packages.id, p.id));
}
}
vi.setSystemTime(T0 + 1_000); // a background tick while the fleet is grounded
const sim = await tickOnce(inst);
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('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; const clock = T0 + 123_456;
// Leave exactly 3 open packages (the rest of the seeded set is "done"). // Leave exactly 3 open packages (the rest of the seeded set is "done").
@@ -654,16 +801,18 @@ describe('spawnOpenPackages', () => {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id)); await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
} }
const needed = await spawnOpenPackages(inst, clock, mulberry32(7)); const needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0);
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3); expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); 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 fc = await loadAirportsGeoJson();
const icaoSet = new Set(fc.features.map((f) => f.properties.icao)); const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
for (const p of openRows.slice(3)) { // the spawned ones for (const p of openRows.slice(3)) { // the spawned ones
expect(p.createdSimTime).toBe(clock); // created at game time, not real time 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.originIcao)).toBe(true);
expect(icaoSet.has(p.destIcao)).toBe(true); expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao); expect(p.originIcao).not.toBe(p.destIcao);
@@ -675,15 +824,36 @@ describe('spawnOpenPackages', () => {
} }
// Already at target → nothing more to spawn. // 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 () => { it('keeps the band composition per airport — weights stay inside their band', async () => {
const a = await freshDb('spawn-a.db'); const inst = await freshDb('pool-composition.db');
const b = await freshDb('spawn-b.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; 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]) { for (const inst of [a, b]) {
const rows = await inst.db.select().from(schema.packages); const rows = await inst.db.select().from(schema.packages);
for (const r of rows) { for (const r of rows) {
@@ -691,8 +861,8 @@ describe('spawnOpenPackages', () => {
} }
} }
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const strip = (rows: schema.PackageRow[]) => const strip = (rows: schema.PackageRow[]) =>
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
@@ -700,99 +870,141 @@ describe('spawnOpenPackages', () => {
strip(await b.db.select().from(schema.packages)), strip(await b.db.select().from(schema.packages)),
); );
}); });
});
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => { it('spawns for flying destinations and queued airports, not just grounded locations', async () => {
// The seeded starter plane is grounded at EDDF with no seeded package from const inst = await freshDb('pool-membership.db');
// 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.
for (const p of await inst.db.select().from(schema.packages)) { 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.delete(schema.packages).where(eq(schema.packages.id, p.id));
} }
// Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is
const n = await spawnCoveragePackages(inst, clock, mulberry32(7)); // {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it.
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane) await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); .where(eq(schema.planes.id, 'plane-starter'));
expect(openRows.length).toBe(1); await inst.db.insert(schema.planes).values({
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport id: 'p-2', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
expect(openRows[0].createdSimTime).toBe(clock);
}); });
it('does nothing when every grounded airport already has an open package', async () => { const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
const inst = await freshDb('coverage-covered.db'); expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set
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,
});
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 origins = (await inst.db.select().from(schema.packages)) const origins = (await inst.db.select().from(schema.packages))
.filter((p) => p.status === 'open') .filter((p) => p.status === 'open')
.map((p) => p.originIcao) .map((p) => p.originIcao)
.sort(); .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 () => { it('holds taken slots for their refill delay, then refills them', async () => {
const inst = await freshDb('coverage-flying.db'); const inst = await freshDb('pool-cooldown.db');
for (const p of await inst.db.select().from(schema.packages)) { // Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); // cooldown — see the ponytail note in spawnPoolPackages).
} const all = await inst.db.select().from(schema.packages);
await inst.db.update(schema.planes) const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
.where(eq(schema.planes.id, 'plane-starter')); 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: 'loaded', planeId: 'plane-starter', 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 () => { it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => {
const inst = await freshDb('coverage-tick.db'); const inst = await freshDb('pool-tick.db');
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays // A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here. // 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({ await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300, id: 'p-air', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS, 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 vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
await tickOnce(inst); await tickOnce(inst);
const fromEdff = (await inst.db.select().from(schema.packages)).filter( const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
(p) => p.status === 'open' && p.originIcao === 'EDDF', 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);
});
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
// against the math, not against itself.
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
const fc = await loadAirportsGeoJson();
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
return Math.round(
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
); );
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline }
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
const inst = await freshDb('pool-shared-rep.db');
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
}
});
it('MAX picks the local row when local leads — symmetric to the player-b case (v1.5.0)', async () => {
const inst = await freshDb('pool-shared-rep-local.db');
// Local is far ahead of b's starting rep — a "prefer non-local" implementation would pick b and fail here.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to b's starting rep
}
});
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
const inst = await freshDb('pool-single-rep.db');
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60));
}
}); });
}); });
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number {
return (km / speedKmh) * 3_600_000;
}
+120 -102
View File
@@ -1,20 +1,24 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { eq, inArray } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state'; import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
import { broadcast } from '@/lib/sse'; import { broadcast, subscribedPlayerIds } from '@/lib/sse';
import { import {
generatePackage, JOB_LIFETIME_MS,
MARKET_TARGET_OPEN_PACKAGES, POOL_BANDS,
REFILL_DELAY_MS,
SMALL_REFILL_DELAY_MS,
generatePoolPackage,
mulberry32, mulberry32,
pickDestWithinCap, poolBandOf,
type Rng, type Rng,
type SeedAirport, type SeedAirport,
} from '@/lib/game/seed'; } from '@/lib/game/seed';
@@ -69,8 +73,8 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// #135: advance decision — pure, so unit tests feed it synthetic fleets. The world clock runs at // #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 // full scale only while something is happening (at least one plane flying OR in repair); a fully
// grounded fleet freezes it entirely — no expiry/spawn/repair progress until a dispatch or repair // grounded fleet freezes it entirely — no expiry/delivery/repair progress until a dispatch or
// starts again. // 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 } { export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0; let flying = 0;
let repairing = 0; let repairing = 0;
@@ -87,8 +91,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now(); const nowMs = Date.now();
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress. // #135: a fully grounded fleet freezes the world — no advance, no expiry/delivery/repair progress
// lastRealTickMs always tracks real time, so resuming activity never fast-forwards the frozen span. // (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 planeRows = await inst.db.select().from(schema.planes);
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status }))); 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 // #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale
@@ -100,16 +105,21 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const clockMs = Math.round(sim.clockMs + advanceMs); const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) { if (advanceMs > 0) {
await expirePackages(inst, clockMs); await expireLoadedPackages(inst, clockMs);
await deliverPackages(inst, clockMs); await deliverPackages(inst, clockMs);
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when // 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 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);
} }
// 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 (open-job expiry) 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. // Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs }); await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
@@ -119,12 +129,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
return updated; return updated;
} }
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave // v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 —
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep // it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs
// occupying their slot, deliver no reward on arrival, and abandon for free). // expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and
// Delivered/abandoned packages are terminal and never flip. Returns the count. // never flip. Both return the count flipped.
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> { export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded'])); const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'loaded'));
let n = 0; let n = 0;
for (const p of rows) { for (const p of rows) {
if (p.deadlineSimTime <= clockMs) { if (p.deadlineSimTime <= clockMs) {
@@ -135,6 +145,26 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n; 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). v0.69.0: legacy 'ignored' rows
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
// lifetime, and no new ones can ever be created. Returns the count flipped.
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
// Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
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 // 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 — // 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 // derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
@@ -198,7 +228,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (!type || !from || !to) continue; // no route info — leave alone if (!type || !from || !to) continue; // no route info — leave alone
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; // v1.12.0: the gate includes the takeoff/landing phases (weather-scaled) — see legPhases.
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn — // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used. // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
@@ -221,13 +252,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
for (const pk of onBoard) { for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) { if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id)); await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
// Fresh account read per award so multiple deliveries in one tick all count. // v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
const fresh = (await inst.db.select().from(schema.account))[0]; // multiple deliveries in one tick all count.
if (!fresh) throw new Error('account row missing'); const fresh = await getPlayer(inst, p.ownerId);
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the // v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded). // distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0; const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await inst.db.update(schema.account).set({ await updatePlayerEconomy(inst, p.ownerId, {
cash: fresh.cash + pk.reward, cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100, reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
}); });
@@ -277,15 +308,15 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. // 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 target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel); 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. // v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
const fresh = (await inst.db.select().from(schema.account))[0]; // delivery awards above) so a top-up in this tick sees current cash.
if (!fresh) throw new Error('account row missing'); const fresh = await getPlayer(inst, p.ownerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; 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) { if (cost === 0 || fresh.cash >= cost) {
queue.shift(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
} }
} }
} }
@@ -323,68 +354,28 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
return n; return n;
} }
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the // v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
// SAME builder as the first-run seed (generatePackage), so spawned packages are // (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// indistinguishable from seeded ones. RNG is injectable for deterministic tests. // it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
export async function spawnOpenPackages( // band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded) 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, inst: GameDb,
clockMs: number, clockMs: number,
rng?: Rng, rng?: Rng,
nowMs?: number,
): Promise<number> { ): Promise<number> {
const openCount = ( const real = nowMs ?? Date.now();
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')) const [planeRows, packageRows] = await Promise.all([
).length;
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
if (needed <= 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): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
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.planes),
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')), inst.db.select().from(schema.packages),
]); ]);
// Distinct airports with at least one grounded plane (flying planes don't count — const pool = poolAirports(planeRows);
// they can't take new work until they land). if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues
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 fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ const airports: SeedAirport[] = fc.features.map((f) => ({
@@ -394,30 +385,48 @@ export async function spawnCoveragePackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch. // v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
const account = (await inst.db.select().from(schema.account))[0]; // v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
if (!account) throw new Error('account row missing'); // agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
// player instead of the seeded local row.
const reputation = await getSharedWorldReputation(inst);
let n = 0; let n = 0;
for (const icao of missing) { for (const originIcao of pool) {
const oi = airports.findIndex((a) => a.icao === icao); // Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
if (oi < 0) continue; // defensive — the dataset is the source of all ICACs // terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
// v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs — const openByBand = [0, 0, 0, 0];
// one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged. let blockedUntil: number | null = null;
const di = pickDestWithinCap(airports, oi, rand); 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( await inst.db.insert(schema.packages).values(
generatePackage({ generatePoolPackage({
id: randomUUID(),
airports, airports,
originIcao,
bandIdx,
rng: rand, rng: rand,
nowMs: clockMs, clockMs,
originIcao: icao, id: randomUUID(),
...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior createdRealMs: real,
reputation: account.reputation, reputation,
}), }),
); );
openByBand[bandIdx]++;
n++; n++;
} }
}
}
return n; return n;
} }
@@ -428,13 +437,22 @@ export async function spawnCoveragePackages(
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged, // 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 // not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
// other clients too. // other clients too.
// v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's
// player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only,
// so every client sees its own account row in every push. Same shared projection as GET /api/state
// (getStateSnapshot + sseFrame), so pushed frames can never drift from polled ones. A session whose
// players row vanished mid-stream is skipped per-tick with a log — the others keep flowing.
async function broadcastState(): Promise<void> { async function broadcastState(): Promise<void> {
const frames = new Map<string, string>();
for (const playerId of subscribedPlayerIds()) {
try { try {
broadcast(JSON.stringify(sseFrame(await getStateSnapshot()))); frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId))));
} catch (err) { } catch (err) {
console.error('flight: state broadcast failed', err); console.error('flight: state broadcast failed', err);
} }
} }
if (frames.size > 0) broadcast(frames);
}
export function tickNow(): Promise<schema.SimStateRow> { export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => { return withLock(async () => {
+94 -24
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { weatherFor, type AirportWeather, type WeatherCondition } from '@/lib/weather';
import { import {
arrivalTimeFor, arrivalTimeFor,
BASE_PHASE_MS,
burnRateConstant, burnRateConstant,
FUEL_DENSITY_KG_PER_L, FUEL_DENSITY_KG_PER_L,
fuelAtWeighted, fuelAtWeighted,
guaranteedRangeKm, guaranteedRangeKm,
legPhases,
legPhaseAt,
MAX_PHASE_FACTOR,
MAX_WIND_BURN_FACTOR, MAX_WIND_BURN_FACTOR,
maxRangeKm, maxRangeKm,
phaseFactor,
positionAt, positionAt,
progressAt, progressAt,
requiredFuelFor, requiredFuelFor,
@@ -38,36 +44,98 @@ describe('travelDurationMs / arrivalTimeFor', () => {
expect(() => travelDurationMs(100, 0)).toThrow(); expect(() => travelDurationMs(100, 0)).toThrow();
}); });
it('arrival is departure + duration', () => { it('arrival is departure plus all three segments (v1.12.0)', () => {
const arrival = arrivalTimeFor(DEP, KM, SPEED); const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9); expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBe(DEP + ph.totalMs);
expect(arrival).toBeGreaterThan(DEP); // The cruise part is the old pure duration — phases only add on top.
expect(ph.cruiseMs).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBeGreaterThan(DEP + ph.cruiseMs);
}); });
}); });
describe('progressAt', () => { describe('phaseFactor / legPhases (v1.12.0)', () => {
const duration = travelDurationMs(KM, SPEED); const mk = (condition: WeatherCondition): AirportWeather => ({ condition, windDirDeg: 90, windKt: 15, visibilityM: 8_000 });
it('is 0 before departure and at departure', () => { it('MAX_PHASE_FACTOR is phaseFactor\'s actual maximum over the weather domain', () => {
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0); // Fog is the worst condition — the constant must track the factor function, not a copy.
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0); expect(MAX_PHASE_FACTOR).toBe(phaseFactor(mk('Fog')));
}); });
it('is linear in the middle of the flight', () => { it('stays within [1, MAX_PHASE_FACTOR] and is monotone in condition severity', () => {
for (const c of ['Clear', 'Cloudy', 'Rain', 'Fog'] as const) {
const f = phaseFactor(mk(c));
expect(f).toBeGreaterThanOrEqual(1);
expect(f).toBeLessThanOrEqual(MAX_PHASE_FACTOR);
}
expect(phaseFactor(mk('Clear'))).toBe(1); // clear skies stay at 1×
expect(phaseFactor(mk('Cloudy'))).toBeGreaterThan(phaseFactor(mk('Clear')));
expect(phaseFactor(mk('Rain'))).toBeGreaterThan(phaseFactor(mk('Cloudy')));
expect(phaseFactor(mk('Fog'))).toBe(MAX_PHASE_FACTOR); // worst condition is exactly 2×
});
it('is a deterministic function of the ICAO via weatherFor', () => {
for (const icao of ['EDDF', 'LEMD', 'EHAM', 'KSMF']) {
expect(phaseFactor(weatherFor(icao))).toBe(phaseFactor(weatherFor(icao)));
}
// KSMF is Clear in the deterministic generator — a real ICAO pinned at exactly 1×.
expect(phaseFactor(weatherFor('KSMF'))).toBe(1);
});
it('takeoff scales on ORIGIN weather, landing on DESTINATION weather', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.takeoffMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('EDDF')));
expect(ph.landingMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('LEMD')));
// Non-vacuous: the two airports really do differ (Rain vs Cloudy), so a swapped
// origin/dest would fail these pins.
expect(phaseFactor(weatherFor('EDDF'))).not.toBe(phaseFactor(weatherFor('LEMD')));
});
it('total is the sum of the three segments and exceeds the old pure duration', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.totalMs).toBe(ph.takeoffMs + ph.cruiseMs + ph.landingMs);
expect(ph.totalMs).toBeGreaterThan(travelDurationMs(KM, SPEED));
});
it('legPhaseAt walks takeoff → cruise → landing over the leg', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs - 1)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs + ph.cruiseMs - 1)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.totalMs - 1)).toBe('landing');
});
});
describe('progressAt (piecewise, v1.12.0)', () => {
const ORIGIN = 'EDDF';
const DEST = 'LEMD';
const ph = legPhases(KM, SPEED, ORIGIN, DEST);
it('is 0 before departure and throughout the takeoff hold at the origin', () => {
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP - 1)).toBe(0);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs / 2)).toBe(0);
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs - 1));
expect(p).toEqual({ lat: FROM.lat, lon: FROM.lon }); // still at the gate — no crawl along the route
});
it('is linear over the cruise window', () => {
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few // Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg. // ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9); expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs / 2)).toBeCloseTo(0.5, 9);
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9); expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs * 0.25)).toBeCloseTo(0.25, 9);
}); });
it('is exactly 1 at arrival and stays 1 after', () => { it('is exactly 1 at the start of the landing hold and stays 1 through arrival', () => {
const arrival = DEP + duration; const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs));
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1); expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // parked at the destination gate during landing
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1); const arrival = DEP + ph.totalMs;
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival)).toBe(1);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival + 10_000)).toBe(1);
}); });
it('a zero-distance leg is complete immediately', () => { it('a zero-distance leg has no cruise to progress through', () => {
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1); expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP - 5)).toBe(0); // before departure
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP + 1)).toBe(1); // complete once departed
}); });
}); });
@@ -160,12 +228,14 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK); expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
}); });
it('composes with progressAt for a just-before-arrival snapshot', () => { it('composes with progressAt for a just-before-arrival snapshot (v1.12.0)', () => {
const duration = travelDurationMs(KM, SPEED); const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
const clock = DEP + duration - 1; // one ms before arrival const clock = DEP + ph.totalMs - 1; // one ms before arrival — inside the landing hold
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock)); const progress = progressAt(DEP, KM, SPEED, 'EDDF', 'LEMD', clock);
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate expect(progress).toBe(1); // cruise is done: the plane holds at the destination gate…
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0); const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progress);
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // …so it IS at the gate (v1.12.0 piecewise)
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progress)).toBeGreaterThan(0);
}); });
}); });
+83 -8
View File
@@ -3,6 +3,8 @@
// distance in game time without losing accuracy. All inputs are explicit; there // distance in game time without losing accuracy. All inputs are explicit; there
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes. // is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
import { weatherFor, type AirportWeather } from '@/lib/weather';
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
const toDeg = (r: number) => (r * 180) / Math.PI; const toDeg = (r: number) => (r * 180) / Math.PI;
@@ -12,22 +14,95 @@ export function travelDurationMs(distanceKm: number, speedKmh: number): number {
return (distanceKm / speedKmh) * 3_600_000; return (distanceKm / speedKmh) * 3_600_000;
} }
// Sim-clock time at which the leg completes. // --- Takeoff & landing phases (v1.12.0) ---
export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number { //
return departureSimTime + travelDurationMs(distanceKm, speedKmh); // Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the
// destination, on top of the pure cruise time. Weather at the relevant airport stretches each
// phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases
// burn no fuel — they are pure time, so the weighted-burn model is untouched.
// One phase's duration in clear weather: 10 minutes of sim time.
export const BASE_PHASE_MS = 600_000;
// Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the
// condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a
// function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent
// signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR.
export function phaseFactor(w: AirportWeather): number {
const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3;
return 1 + step / 3;
} }
// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before // Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition).
// departure and 1 after arrival. A zero-distance leg is complete immediately. export const MAX_PHASE_FACTOR = 2;
export interface LegPhases {
takeoffMs: number; // origin weather
cruiseMs: number; // pure cruise — the old travelDurationMs value
landingMs: number; // destination weather
totalMs: number; // takeoff + cruise + landing
}
// The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION
// weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the
// two ICAs recomputes exactly what the engine used — no stored state needed.
export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases {
const cruiseMs = travelDurationMs(distanceKm, speedKmh);
const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao));
const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao));
return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs };
}
// Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0).
export function arrivalTimeFor(
departureSimTime: number,
distanceKm: number,
speedKmh: number,
originIcao: string,
destIcao: string,
): number {
return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs;
}
// CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before
// departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear
// over the cruise window, 1 while it holds at the destination during landing. That keeps
// positionAt/fuel pinned to each airport during its phase instead of crawling along the whole
// route. A zero-distance leg has no cruise: complete once departed.
export function progressAt( export function progressAt(
departureSimTime: number, departureSimTime: number,
distanceKm: number, distanceKm: number,
speedKmh: number, speedKmh: number,
originIcao: string,
destIcao: string,
clockMs: number, clockMs: number,
): number { ): number {
const duration = travelDurationMs(distanceKm, speedKmh); const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
if (duration <= 0) return 1; const t = clockMs - departureSimTime;
return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration)); if (t <= 0) return 0;
if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise
if (t >= ph.totalMs) return 1;
if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff
if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing
return (t - ph.takeoffMs) / ph.cruiseMs;
}
export type LegPhase = 'takeoff' | 'cruise' | 'landing';
// Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0).
export function legPhaseAt(
departureSimTime: number,
distanceKm: number,
speedKmh: number,
originIcao: string,
destIcao: string,
clockMs: number,
): LegPhase {
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
const t = clockMs - departureSimTime;
if (t < ph.takeoffMs) return 'takeoff';
if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise';
return 'landing';
} }
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1] // Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]
+38 -18
View File
@@ -1,45 +1,65 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { broadcast, subscribe } from './sse'; import { broadcast, subscribedPlayerIds, subscribe } from './sse';
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or // ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
// server needed. Subscribers leak across tests in this file (module-level state), which is fine: // leak across tests in this file (module-level state), which is fine: each test asserts on its own
// each test only asserts on its own arrays. // arrays and uses distinct player ids where isolation matters. v1.4.0 (ISS-112): keyed by PLAYER ID
// — a frame for one player must never reach another player's streams.
describe('lib/sse', () => { describe('lib/sse', () => {
it('delivers broadcasts to subscribers and stops after unsubscribe', () => { it('delivers broadcasts to subscribers of the SAME player only, and stops after unsubscribe', () => {
const a: string[] = []; const a: string[] = [];
const b: string[] = []; const b: string[] = []; // same player as a — both get its frames
const unsubA = subscribe((t) => a.push(t)); const c: string[] = []; // different player — never sees their frames
const unsubB = subscribe((t) => b.push(t)); const unsubA = subscribe('p-a', (t) => a.push(t));
const unsubB = subscribe('p-a', (t) => b.push(t));
const unsubC = subscribe('p-c', (t) => c.push(t));
broadcast('x'); broadcast(new Map([['p-a', 'x']]));
expect(a).toEqual(['x']); expect(a).toEqual(['x']);
expect(b).toEqual(['x']); expect(b).toEqual(['x']); // same group
expect(c).toEqual([]); // other player's frame never crosses over
unsubA(); unsubA();
broadcast('y'); broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
expect(a).toEqual(['x']); // unsubscribed — no more events expect(a).toEqual(['x']); // unsubscribed — no more events
expect(b).toEqual(['x', 'y']); expect(b).toEqual(['x', 'y']);
expect(c).toEqual(['z']);
unsubB(); unsubB();
unsubC();
}); });
it('a throwing subscriber does not break the others', () => { it('subscribedPlayerIds lists exactly the players with live streams, dropping empty groups', () => {
const got: string[] = []; const got: string[] = [];
subscribe(() => { const unsub1 = subscribe('p-ids-1', (t) => got.push(t));
const unsub2 = subscribe('p-ids-1', (t) => got.push(t)); // second member of the same group
const unsub3 = subscribe('p-ids-2', (t) => got.push(t));
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
unsub1(); // p-ids-1 still has one member — id stays listed
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
unsub3(); // p-ids-2's last stream goes → its projections stop
expect(subscribedPlayerIds()).toEqual(['p-ids-1']);
unsub2();
});
it('a throwing subscriber does not break the others in its group', () => {
const got: string[] = [];
subscribe('p-boom', () => {
throw new Error('boom'); throw new Error('boom');
}); });
const unsub = subscribe((t) => got.push(t)); const unsub = subscribe('p-boom', (t) => got.push(t));
broadcast('z'); broadcast(new Map([['p-boom', 'z']]));
expect(got).toEqual(['z']); expect(got).toEqual(['z']);
unsub(); unsub();
}); });
it('unsubscribe is idempotent', () => { it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
const got: string[] = []; const got: string[] = [];
const unsub = subscribe((t) => got.push(t)); const unsub = subscribe('p-idem', (t) => got.push(t));
unsub(); unsub();
unsub(); // second call must not throw unsub(); // second call must not throw
broadcast('w'); broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
expect(got).toEqual([]); expect(got).toEqual([]);
}); });
}); });
+31 -10
View File
@@ -1,25 +1,45 @@
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app // 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 // 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 // sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route // 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 // 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. // give each its own empty map and no push would ever be delivered. v1.4.0 (ISS-112): subscribers are
// keyed by PLAYER ID — the engine projects one snapshot per connected player and pushes each frame
// only to that player's group, so every client sees its own account row in every pushed frame.
type Push = (text: string) => void; type Push = (text: string) => void;
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> }; const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set()); const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
export function subscribe(fn: Push): () => void { export function subscribe(playerId: string, fn: Push): () => void {
subscribers.add(fn); let group = subscribers.get(playerId);
if (!group) {
group = new Set();
subscribers.set(playerId, group);
}
group.add(fn);
return () => { return () => {
subscribers.delete(fn); const s = subscribers.get(playerId);
if (!s) return; // already unsubscribed — idempotent
s.delete(fn);
if (s.size === 0) subscribers.delete(playerId); // drop the empty group so projections stop for it
}; };
} }
// Call each subscriber safely — one dead stream must not take the others down. // v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
export function broadcast(text: string): void { // projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
for (const fn of [...subscribers]) { export function subscribedPlayerIds(): string[] {
return [...subscribers.keys()];
}
// Call each subscriber safely — one dead stream must not take the others down. `frames` maps
// player id → pre-serialized frame text (the engine projects per player, this only fans out).
export function broadcast(frames: Map<string, string>): void {
for (const [playerId, text] of frames) {
const group = subscribers.get(playerId);
if (!group) continue; // unsubscribed between projection and push — fine
for (const fn of [...group]) {
try { try {
fn(text); fn(text);
} catch { } catch {
@@ -27,3 +47,4 @@ export function broadcast(text: string): void {
} }
} }
} }
}

Some files were not shown because too many files have changed in this diff Show More