Compare commits

...
Author SHA1 Message Date
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
94 changed files with 4699 additions and 1209 deletions
+58 -2
View File
@@ -189,10 +189,56 @@ jobs:
# in-network app host out of any proxy path.
NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl
no_proxy: app,localhost,127.0.0.1,.kkhost.pl
# Machine-readable e2e results for the job-summary step below (Playwright's built-in
# json reporter; without this env var it would dump to stdout). test-results/ is git-ignored.
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/e2e.json
# Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
# visibility in the job log; failure error context/snapshots are unchanged.
run: npx playwright test --reporter=list
# visibility in the job log; failure error context/snapshots are unchanged. The json
# reporter adds a machine-readable report (PLAYWRIGHT_JSON_OUTPUT_FILE above) — no
# execution behavior changes, reporters only observe.
run: npx playwright test --reporter=list,json
# Appends e2e results to the job-summary panel — mirrors how unit tests get there
# (vitest's built-in github-actions reporter auto-appends $GITHUB_STEP_SUMMARY in CI).
# if: always() so a FAILED suite still gets summarized; non-fatal by design — a missing
# or malformed report must never mask the real e2e exit code. The script is single-quoted
# for bash, so it contains no apostrophes.
- name: E2E job summary
if: always()
run: |
node -e '
const fs = require("fs");
let report;
try {
report = JSON.parse(fs.readFileSync("test-results/e2e.json", "utf8"));
} catch {
process.exit(0); // e2e never ran (earlier step failed) or no report — nothing to summarize
}
let passed = 0, skipped = 0;
const failed = [];
const walk = (suite) => {
for (const spec of suite.specs ?? []) {
if (!spec.ok) failed.push(`${suite.title} › ${spec.title}`);
else if ((spec.tests ?? []).length && spec.tests.every((t) => t.expectedStatus === "skipped")) skipped++;
else passed++;
}
for (const sub of suite.suites ?? []) walk(sub);
};
for (const s of report.suites ?? []) walk(s);
const lines = [
"", "## E2E results", "",
`- Passed: ${passed}`,
`- Failed: ${failed.length}`,
`- Skipped: ${skipped}`,
];
if (failed.length) {
lines.push("", "### Failed specs");
for (const f of failed) lines.push(`- \`${f}\``);
}
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (summaryFile) fs.appendFileSync(summaryFile, lines.join("\n") + "\n");
' || true
# The daemon is shared with production — never leave our stack or network behind.
# -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
@@ -201,3 +247,13 @@ jobs:
run: |
docker compose -f docker-compose.ci.yml -p flight-ci down --remove-orphans -v || true
docker network remove flight-ci-e2e 2>/dev/null || true
# The shared runner dind still accumulates between runs what the steps above can't reach:
# dangling images/layers from the per-run flight-ops-ci rebuilds, BuildKit cache, and job
# containers leaked by interrupted runs. Prune them here (after stack + network cleanup).
# Deliberately NO --volumes: the daemon is shared with production and named volumes may hold
# other workflows' persistent data — this project's own flight-ci_flight-db volume is already
# dropped by the steps above. Non-fatal so a prune failure can never mask or break the run.
- name: Prune runner dind leftovers
if: always()
run: docker system prune -af || echo "::warning::dind prune failed — see logs"
+66
View File
@@ -1,5 +1,71 @@
# Changelog
## 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.
@@ -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]) })));
}
+16 -1
View File
@@ -2,9 +2,12 @@ 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 { createSession } from '@/lib/auth';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
@@ -12,6 +15,8 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
// 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 () => {
@@ -20,8 +25,18 @@ afterAll(async () => {
});
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 () => {
const res = await GET();
const res = await GET(req());
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache');
+14 -4
View File
@@ -1,3 +1,4 @@
import { requireAuthSse } from '@/lib/auth';
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
import { subscribe } from '@/lib/sse';
@@ -5,12 +6,19 @@ import { subscribe } from '@/lib/sse';
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
// pushed frames, so a connect never costs one full-history frame.
// 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).
const encoder = new TextEncoder();
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 unsubscribe: (() => void) | null = null;
const cleanup = () => {
@@ -22,8 +30,10 @@ export async function GET() {
start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
// for the next push instead of seeing an out-of-order frame.
getStateSnapshot()
// for the next push instead of seeing an out-of-order frame. v1.3.0 (ISS-110): the connect
// frame carries THIS session's account row; later pushes come from the shared broadcast
// (local player) until per-player frames land (#112).
getStateSnapshot(auth.player.id)
.then((snapshot) => {
unsubscribe = subscribe((text) => {
try {
+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 path from 'node:path';
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 { createSession } from '@/lib/auth';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
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 () => {
@@ -21,7 +25,14 @@ afterAll(async () => {
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', () => {
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);
// Cash untouched by the failed attempt.
const account = (await (await getGameDb()).db.select().from(schema.account))[0];
expect(account.cash).toBe(20_000);
expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000);
});
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
const { db } = await getGameDb();
// Single-row account table — top up cash so the purchase can succeed.
await db.update(schema.account).set({ cash: 500_000 });
const inst = await getGameDb();
// Local player row — top up cash so the purchase can succeed.
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const res = await post({ model: 'atr72' });
expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
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 () => {
@@ -22,7 +27,14 @@ afterAll(async () => {
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', () => {
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();
// A second, grounded plane so "flying" is the reason for rejection.
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,
});
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 () => {
const { db } = await getGameDb();
const inst = await getGameDb();
// 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'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post({ planeId: 'plane-extra' });
expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
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 });
}
+14 -6
View File
@@ -4,16 +4,21 @@ import path from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
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 () => {
@@ -21,9 +26,11 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
params: Promise.resolve({ id }),
});
const post = (id: string) =>
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
// 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();
await db.insert(schema.packages).values({
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — stay open past the pre-action tick
});
const res = await post('p-open');
@@ -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 () => {
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
const { db } = await getGameDb();
const cashBefore = (await db.select().from(schema.account))[0].cash;
const inst = await getGameDb();
const { db } = inst;
const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post('p-ab');
expect(res.status).toBe(200);
+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
// 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;
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));
}
+15 -6
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
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 () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
});
const post = (id: string, body: unknown) =>
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ id }),
});
POST(
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', () => {
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();
await db.insert(schema.packages).values({
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-ghost-plane', { planeId: 'ghost' });
@@ -57,7 +66,7 @@ describe('POST /api/packages/:id/load', () => {
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
await db.insert(schema.packages).values({
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
@@ -82,7 +91,7 @@ describe('POST /api/packages/:id/load', () => {
// A known open package originating where the starter actually sits (EDDF).
await db.insert(schema.packages).values({
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
});
const res = await post('p-home', { planeId: 'plane-starter' });
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
}
const planeId = input.planeId;
return performAction((inst) => loadPackage(inst, id, planeId));
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
}
+12 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
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 () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
});
const post = (planeId: string, body: unknown) =>
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ planeId }),
});
POST(
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', () => {
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
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;
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;
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.
// 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;
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 } });
}
+33 -4
View File
@@ -5,9 +5,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never
@@ -15,8 +19,14 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
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 () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
@@ -24,7 +34,7 @@ afterAll(async () => {
describe('GET /api/state', () => {
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.headers.get('content-type')).toContain('application/json');
@@ -75,13 +85,13 @@ describe('GET /api/state', () => {
});
it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const a = ((await GET(req())).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);
});
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>>;
fuelPricePerL: number;
sellFraction: number;
@@ -104,4 +114,23 @@ describe('GET /api/state', () => {
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
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
});
});
+8 -2
View File
@@ -1,12 +1,18 @@
import { requireAuth } from '@/lib/auth';
import { tickNow } from '@/lib/sim/engine';
import { getStateSnapshot } from '@/lib/game/state';
export async function GET() {
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
// unchanged; per-player snapshots are a later step (#112).
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
export async function GET(req: Request) {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try {
// 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).
await tickNow();
return Response.json(await getStateSnapshot());
return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) {
console.error('failed to load game state', err);
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 * as schema from '@/lib/db/schema';
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -46,11 +46,12 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 });
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
// player the e2e suite plays via mint-player-session's default).
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
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 });
}
+3 -3
View File
@@ -1,5 +1,6 @@
import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed';
@@ -84,8 +85,7 @@ export async function POST(req: Request) {
const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// as engine spawns; never force a fixed value here.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const player = await getLocalPlayer(inst);
const created = generatePackage({
id: randomUUID(),
airports,
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null),
reputation: account.reputation,
reputation: player.reputation,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+24 -15
View File
@@ -3,21 +3,22 @@ import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed';
import { generatePackage, HUB_ICAO, JOB_LIFETIME_MS, mulberry32 } from '@/lib/game/seed';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite
// observe package expiry without waiting hours of game time. Disabled unless
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance
// only; every other deployment 404s here.
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0; reworked for wall-clock expiry in v0.65.0) —
// lets the e2e suite observe open-job expiry without waiting an hour of real time. Disabled unless
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance only; every other
// deployment 404s here.
//
// POST /api/test/expire-package
// body { packageId?: string }
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS
// (game time) so the next fast tick expires it; 404 if unknown/not open.
// - without: creates a fresh open package with the same short deadline.
// - with packageId: ages that OPEN package's createdRealMs to HOOK_AGE_MS in the past so the next
// tick (run immediately via tickNow) flips it to 'expired'; 404 if unknown/not open.
// - without: creates a fresh open package at the home hub, already aged past its lifetime — visible
// while open (the hub is always in the pool), expired by the time the caller polls /api/state.
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (≈ 0.01 s real at the fastest 1000×)
const HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000; // comfortably past the job lifetime
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
@@ -41,8 +42,6 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before we set the deadline
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
if (typeof packageId === 'string') {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
@@ -50,8 +49,11 @@ export async function POST(req: Request) {
if (!row || row.status !== 'open') {
return Response.json({ error: 'package not found or not open' }, { status: 404 });
}
await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId));
await inst.db.update(schema.packages).set({ createdRealMs: Date.now() - HOOK_AGE_MS }).where(eq(schema.packages.id, packageId));
await persist(inst);
// AFTER the mutation on purpose: tickNow runs a full tickOnce, so expireOpenPackages flips the
// aged row to 'expired' within this same call — callers can poll /api/state immediately.
await tickNow();
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
return Response.json(updated ?? row);
}
@@ -64,11 +66,18 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
// special: null — the hook never rolls a special status (v0.37.0).
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
// nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs
// is real time. special: null — the hook never rolls a special status (v0.37.0). Origin forced to
// the home hub so the job is visible while open; already aged past its lifetime, it expires on
// the tick below.
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, originIcao: HUB_ICAO, special: null, createdRealMs: Date.now() - HOOK_AGE_MS });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
await tickNow();
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, created.id)))[0];
return Response.json(updated ?? created, { status: 201 });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
@@ -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 * as schema from '@/lib/db/schema';
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -42,11 +42,12 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
// the e2e suite plays via mint-player-session's default).
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);
return Response.json(await getStateSnapshot());
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
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';
@@ -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 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) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
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';
@@ -74,7 +75,8 @@ export async function POST(req: Request) {
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 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) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+210
View File
@@ -588,3 +588,213 @@ body {
font-weight: 600;
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.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;
}
+47 -1
View File
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane';
import SignInScreen from '@/components/SignInScreen';
import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes';
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents';
@@ -13,6 +15,50 @@ import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
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);
useEffect(() => {
const sid = storedSession();
if (!sid) {
setAuthed(false);
return;
}
let cancelled = false;
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
.then((res) => {
if (cancelled) return;
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
setAuthed(res.ok);
})
.catch(() => {
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
});
return () => {
cancelled = true;
};
}, []);
if (authed === null) {
return (
<div className="app-shell">
<p data-testid="auth-checking" className="sign-in-loading">
Loading…
</p>
</div>
);
}
if (!authed) {
return <SignInScreen onAuthed={() => setAuthed(true)} />;
}
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
return <AppShell onLogout={() => setAuthed(false)} />;
}
function AppShell({ onLogout }: { onLogout: () => void }) {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately.
@@ -49,7 +95,7 @@ export default function Home() {
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */}
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<ToastStack toasts={toasts} onDismiss={dismiss} />
</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>
);
}
+26 -6
View File
@@ -11,12 +11,18 @@ import type { HoverTarget } from './hoverTypes';
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal';
import AdminPanel from './AdminPanel';
import { sessionHeaders } from '../lib/authClient';
const EUROPE_CENTER: [number, number] = [10, 52];
const INITIAL_ZOOM = 4;
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there 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.
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
// 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;
const AIRPORTS_SOURCE_ID = 'airports';
@@ -64,11 +70,14 @@ interface MapProps {
// 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.
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;
}
// 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.
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event
@@ -86,6 +95,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null);
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);
// 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
@@ -218,7 +230,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const map = new maplibregl.Map({
container: containerRef.current,
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
center: EUROPE_CENTER,
center: WORLD_CENTER,
zoom: INITIAL_ZOOM,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
@@ -287,7 +299,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
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 }),
cache: 'no-store',
});
@@ -576,8 +589,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
<span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin
</button>
</header>
<div ref={containerRef} data-testid="map" className="map-container" />
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel onLogout={onLogout} />
</Modal>
</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>
);
}
+3 -11
View File
@@ -221,16 +221,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load
</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 className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
@@ -238,8 +228,10 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
{/* v0.65.0: open jobs expire on the WALL clock — live countdown from expiresRealMs;
re-renders every poll tick (~1 s) keep it current without a timer. */}
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
{formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())}
</span>
</div>
</li>
+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>
);
}
+1
View File
@@ -47,6 +47,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
status: 'open',
planeId: null,
createdSimTime: 0,
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
...over,
};
}
+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","齐齐哈尔, 齐齐哈尔三家子机场"
"27242","ZYTL","large_airport","Dalian Zhoushuizi International Airport","38.965719","121.538477","107","AS","CN","CN-21","Dalian (Ganjingzi)","yes","ZYTL","DLC","ZYTL","","https://dlairport.com","https://en.wikipedia.org/wiki/Dalian_Zhoushuizi_International_Airport","Dalian Air Base"
"27243","ZYTX","large_airport","Shenyang Taoxian International Airport","41.6398","123.483668","198","AS","CN","CN-21","Shenyang","yes","ZYTX","SHE","ZYTX","","","https://en.wikipedia.org/wiki/Shenyang_Taoxian_International_Airport","沈阳, 沈阳桃仙国际机场"
"16155","AK62","seaplane_base","Naukati Bay Seaplane Base","55.849602","-133.227994","","NA","US","US-AK","Tuxekan Island","yes","","NKI","AK62","AK62","","https://en.wikipedia.org/wiki/Naukati_Bay_Seaplane_Base","62A, Nichin Cove Seaplane Base, WNC"
"8159","19P","seaplane_base","Port Protection Seaplane Base","56.3288","-133.610001","","NA","US","US-AK","Port Protection","yes","","PPV","19P","19P","","https://en.wikipedia.org/wiki/Port_Protection_Seaplane_Base","AK19"
"9296","2AK8","small_airport","Icy Cape Air Force Station Airport","70.293545","-161.907151","48","NA","US","US-AK","Icy Cape","no","","","2AK8","2AK8","","",""
"9902","2Z1","seaplane_base","Entrance Island Seaplane Base","57.412201","-133.43848","0","NA","US","US-AK","Entrance Island","no","","HBH","","","","","2Z1"
"5398","PAPG","medium_airport","Petersburg James A Johnson Airport","56.80170059","-132.9450073","111","NA","US","US-AK","Petersburg","yes","PAPG","PSG","PAPG","PSG","","https://en.wikipedia.org/wiki/Petersburg_James_A._Johnson_Airport",""
"5421","PAWG","medium_airport","Wrangell Airport","56.48429871","-132.3699951","49","NA","US","US-AK","Wrangell","yes","PAWG","WRG","PAWG","WRG","","https://en.wikipedia.org/wiki/Wrangell_Airport",""
"19240","KAE","seaplane_base","Kake Seaplane Base","56.973","-133.945999","","NA","US","US-AK","Kake","yes","","KAE","","KAE","","https://en.wikipedia.org/wiki/Kake_Seaplane_Base",""
"14860","PABU","small_airport","Bullen Point Short Range Radar Station Airport","70.170999","-146.844897","18","NA","US","US-AK","Kaktovik","no","","","PABU","8AK7","","https://en.wikipedia.org/wiki/Bullen_Point_Short_Range_Radar_Site",""
"307366","EGAR","small_airport","Rothera Research Station","-67.5675","-68.127403","9","AN","AQ","AQ-U-A","Rothera Research Station","no","EGAR","","EGAR","","http://www.antarctica.ac.uk/living_and_working/research_stations/rothera/","https://en.wikipedia.org/wiki/Rothera_Research_Station",""
"5405","PASC","medium_airport","Deadhorse Airport","70.19470215","-148.4649963","65","NA","US","US-AK","Deadhorse","yes","PASC","SCC","PASC","SCC","","https://en.wikipedia.org/wiki/Deadhorse_Airport",""
"5401","PAQT","medium_airport","Nuiqsut Airport","70.209999","-151.005998","38","NA","US","US-AK","Nuiqsut","yes","PAQT","NUI","PAQT","AQT","","https://en.wikipedia.org/wiki/Nuiqsut_Airport","10AK"
"312361","ANZ","small_airport","Angus Downs Airport","-25.0325","132.2748","1724","OC","AU","AU-NT","Angus Downs Station","no","","ANZ","YADN","","","",""
"39579","AQ-0001","small_airport","Novolazarevskaya Station","-70.8461","11.8472","390","AN","AQ","AQ-U-A","Queen Maud Land","no","","","AT17","","","https://en.wikipedia.org/wiki/Novolazarevskaya_Station",""
"42221","AQ-0002","small_airport","Troll Airfield","-71.95714","2.45257","4167","AN","AQ","AQ-U-A","Troll Station","no","","","ENOE","AT27","","https://en.wikipedia.org/wiki/Troll_airfield",""
"336951","AQBC","medium_airport","Boulder Clay Runway","-74.739373","164.03539","672","AN","AQ","AQ-U-A","Zucchelli Station","no","","","AQBC","","","https://en.wikipedia.org/wiki/Zucchelli_Station",""
"345862","AQ-0011","small_airport","Wolfs Fang Runway","-71.545026","8.798389","3707","AN","AQ","AQ-U-A","","no","","","","","https://white-desert.com/about-us/wolfsfang-runway-logistics/","https://en.wikipedia.org/wiki/Wolfs_Fang_Runway","AT98, Henrickson Nunatak, Queen Maud Land"
"5054","NZSP","medium_airport","Amundsen–Scott South Pole Station Airport","-90","-1","9300","AN","AQ","AQ-U-A","Amundsen-Scott South Pole Station","no","NZSP","","NZSP","","","https://en.wikipedia.org/wiki/Amundsen-Scott_South_Pole_Station",""
"603000","AQ-0016","medium_airport","Grandfather Skiway","-77.530242","-31.827393","","AN","AQ","AQ-U-A","Antartica","no","","","","","","",""
"336953","AT03","medium_airport","Concordia Skyway","-75.103278","123.35825","10725","AN","AQ","AQ-U-A","Concordia Station","no","","","AT03","","","https://en.wikipedia.org/wiki/Concordia_Station",""
"330063","AT25","small_airport","Showa Station Skiway","-69.006167","39.59","95","AN","AQ","AQ-U-A","Showa Station","no","","","AT25","","","https://en.wikipedia.org/wiki/Showa_Station_(Antarctica)","Syowa Station"
"336959","AT28","medium_airport","Vostok Skiway","-78.466139","106.84825","11447","AN","AQ","AQ-U-A","Vostok Station","no","","","AT28","","","https://en.wikipedia.org/wiki/Vostok_Station",""
"333856","AU-0112","small_airport","Aileron Airstrip","-22.652908","133.347244","2186","OC","AU","AU-NT","Anmatjere","no","","","YALR","","","",""
"351706","AU-0272","small_airport","Planet Downs OS","-25.872662","141.116788","","OC","AU","AU-QLD","Planet downs OS","no","","","","","","",""
"352795","AU-0345","small_airport","Hamilton Downs Airstrip","-23.507335","133.260577","","OC","AU","AU-NT","Burt Plain","no","","","","","","",""
"27846","YNPB","small_airport","Napperby Airport","-22.5312","132.7632","2135","OC","AU","AU-NT","Napperby","no","","NPP","YNPB","","","",""
"27793","YMNY","small_airport","Morney Airport","-25.358299255371094","141.43299865722656","104","OC","AU","AU-QLD","","no","YMNY","OXY","YMNY","","","",""
"507903","AU-0571","small_airport","Lake MacKay Airstrip","-23.670782","129.976158","","OC","AU","AU-NT","Lake MacKay","no","","","","","","",""
"508151","AU-0586","small_airport","homestead","-23.317075","138.587637","","OC","AU","AU-NT","Sturt QLD 4829","no","","","","","","",""
"38379","YHBY","small_airport","Henbury Airport","-24.584","133.236","","OC","AU","AU-NT","Ghan","no","YHBY","HRY","YHBY","","","",""
"31745","AU-KGR","small_airport","Kulgera Airport","-25.8428","133.292007","","OC","AU","AU-NT","Ghan","no","","KGR","","","","",""
"44051","BGDH","small_airport","Danmarkshavn Landing Strip","76.76667","-18.666668","","NA","GL","GL-NE","Danmarkshavn","no","","","BGDH","","","",""
"299843","BGMI","small_airport","Station Nord Landing Strip","81.601292","-16.679306","35","NA","GL","GL-NE","Station Nord","no","","","BGMI","","","https://en.wikipedia.org/wiki/Nord,_Greenland",""
"32004","BGQQ","medium_airport","Qaanaaq Airport","77.488602","-69.388702","51","NA","GL","GL-AV","Qaanaaq","yes","BGQQ","NAQ","BGQQ","","","https://en.wikipedia.org/wiki/Qaanaaq_Airport","Mittarfik Qaanaaq, Thule"
"116","BGTL","medium_airport","Pituffik Space Base","76.53063","-68.700541","251","NA","GL","GL-AV","Pituffik","no","BGTL","THU","BGTL","","","https://en.wikipedia.org/wiki/Pituffik_Space_Base","Thule Air Base"
"874","CEM3","small_airport","Whatì Airport","63.131699","-117.246002","882","NA","CA","CA-NT","Whatì","yes","","YLE","CEM3","CEM3","","https://en.wikipedia.org/wiki/What%C3%AC_Airport","EM3, Lac la Martre, Wha Ti"
"299911","CA-0504","small_airport","Mary River Aerodrome","71.324167","-79.356944","584","NA","CA","CA-NU","Mary River","no","","YMV","","CMR2","","https://en.wikipedia.org/wiki/Mary_River_Aerodrome",""
"316098","CA-0551","seaplane_base","Cameron Bay Water Aerodrome","66.06","-117.89","512","NA","CA","CA-NT","Cameron Bay","no","","","","EA7","","",""
"316114","CA-0554","seaplane_base","Clarence Lagoon Water Aerodrome","69.62","-140.81","0","NA","CA","CA-YT","","no","","","","EC7","","",""
"316197","CA-0556","seaplane_base","Boffa Lake Water Aerodrome","69.637","-116.196","338","NA","CA","CA-NU","","no","","","","EX6","","",""
"342506","CA-0737","small_airport","Seal River Airstrip","59.154598","-94.785554","20","NA","CA","CA-MB","Churchill","no","","","","","","",""
"342507","CA-0738","small_airport","Nester One Airstrip","58.6529","-93.19061","26","NA","CA","CA-MB","Churchill","no","","","","","","",""
"723","CBU2","small_airport","Eddontenajon / Iskut Village Airport","57.847994155","-129.983968735","3100","NA","CA","CA-BC","Eddontenajon","no","","","CBU2","CBU2","","https://en.wikipedia.org/wiki/Eddontenajon/Iskut_Village_Airport","BU2"
"349351","CA-0771","small_airport","Arctic Watch Lodge Airport","74.065757","-93.785965","","NA","CA","CA-NU","Somerset Island","no","","","CRW4","RW4","","https://en.wikipedia.org/wiki/Arctic_Watch_Lodge_Aerodrome",""
"430490","CA-0815","small_airport","Nuskenford Airstrip","69.863861","-126.843167","","NA","CA","CA-NT","Nuskenford","no","","","","","","",""
"524817","CA-0924","seaplane_base","Herschel Island Water Aerodrome","69.572","-138.93","0","NA","CA","CA-YT","Herschel","no","","","","EG8","","",""
"595702","CA-1179","small_airport","Cassiar Aerodrome","59.277137","-129.805044","3618","NA","CA","CA-BC","Cassiar","no","","","","AU9","","",""
"606385","CA-1249","small_airport","Fort Conger Airstrip","81.754925","-64.824958","15","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1249","","","FCON"
"606387","CA-1250","small_airport","Lake Hazen Airstrip","81.823025","-71.360321","10","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1250","","","LHAZ"
"606493","CA-1251","small_airport","McGill Arctic Research Station (MARS)","79.41596","-90.761018","","NA","CA","CA-NU","Axel Heiberg Island","no","","","","","","",""
"607046","CA-1252","small_airport","Ward Hunt Island Airport","83.091029","-74.138317","","NA","CA","CA-NU","Qikiqtaaluk Region","no","","","","CA1252","","https://en.wikipedia.org/wiki/Ward_Hunt_Island",""
"609524","CA-1259","small_airport","Bathurst Inlet Airport","66.836865","-108.011278","36","NA","CA","CA-NU","Bathurst Inlet/Qingaut","no","","","","","","","ᕿᙵᐅᓐ"
"28437","CAH9","seaplane_base","Telegraph Creek Seaplane Base","57.9071","-131.18946","1129","NA","CA","CA-BC","Telegraph Creek","no","","","CAH9","CAH9","","https://en.wikipedia.org/wiki/Telegraph_Creek_Water_Aerodrome","AH9"
"665","CBC2","small_airport","Ford Bay Airport","66.0374984741211","-124.71499633789062","673","NA","CA","CA-NT","Ford Bay","no","","","CBC2","CBC2","","https://en.wikipedia.org/wiki/Ford_Bay_Airport","BC2"
"672","CBD6","small_airport","Nahanni Butte Airport","61.029701232910156","-123.38899993896484","600","NA","CA","CA-NT","Nahanni Butte","no","","","CBD6","CBD6","","https://en.wikipedia.org/wiki/Nahanni_Butte_Airport","BD6"
"686","CBH4","small_airport","Prairie Creek Airport","61.564701080322266","-124.81500244140625","2950","NA","CA","CA-NT","Prairie Creek","no","","","CBH4","CBH4","","https://en.wikipedia.org/wiki/Prairie_Creek_Airport","BH4"
"736","CBX5","small_airport","Tungsten (Cantung) Airport","61.956901550293","-128.20300292969","3500","NA","CA","CA-NT","Tungsten","no","","TNS","CBX5","CBX5","","https://en.wikipedia.org/wiki/Tungsten_(Cantung)_Airport","BX5"
"814","CDK2","small_airport","Diavik Airport","64.5113983154","-110.289001465","1413","NA","CA","CA-NT","Diavik","no","","DVK","CDK2","CDK2","","https://en.wikipedia.org/wiki/Diavik_Airport","DK2"
"43879","CDL7","small_airport","Doris Lake","68.1252746582","-106.585281372","50","NA","CA","CA-NU","Hope Bay","no","","JOJ","","CDL7","","https://en.wikipedia.org/wiki/Doris_Lake_Aerodrome",""
"1802","CYLK","small_airport","Lutselk'e Airport","62.417823","-110.682793","596","NA","CA","CA-NT","Lutselk'e","yes","CYLK","YSG","CYLK","","","https://en.wikipedia.org/wiki/Lutselk'e_Airport","YLK, EL3, Snowdrift Airport"
"314953","CYVL","small_airport","Tommy Kochon Airport","67.02015","-126.128364","870","NA","CA","CA-NT","Colville Lake","yes","CYVL","YCK","CYVL","","","https://en.wikipedia.org/wiki/Colville_Lake/Tommy_Kochon_Aerodrome",""
"925","CFA7","small_airport","Taltheilei Narrows Airport","62.5980987549","-111.542999268","570","NA","CA","CA-NT","Taltheilei Narrows","no","","GSL","CFA7","CFA7","","https://en.wikipedia.org/wiki/Taltheilei_Narrows_Airport","FA7"
"1726","CYEV","medium_airport","Inuvik Mike Zubko Airport","68.30419921880001","-133.483001709","224","NA","CA","CA-NT","Inuvik","yes","CYEV","YEV","CYEV","","","https://en.wikipedia.org/wiki/Inuvik_(Mike_Zubko)_Airport",""
"1868","CYQH","medium_airport","Watson Lake Airport","60.116839","-128.821993","2255","NA","CA","CA-YT","Watson Lake","yes","CYQH","YQH","CYQH","","","https://en.wikipedia.org/wiki/Watson_Lake_Airport",""
"1948","CYWJ","small_airport","Déline Airport","65.211098","-123.435997","703","NA","CA","CA-NT","Déline","yes","CYWJ","YWJ","CYWJ","","","https://en.wikipedia.org/wiki/Deline_Airport","Délı̨nę, Fort Franklin"
"1993","CYZF","medium_airport","Yellowknife International Airport","62.462799","-114.440002","675","NA","CA","CA-NT","Yellowknife","yes","CYZF","YZF","CYZF","","https://www.inf.gov.nt.ca/en/services/airports/yellowknife-airport","https://en.wikipedia.org/wiki/Yellowknife_Airport",""
"28331","CEP9","seaplane_base","Namushka Lodge Seaplane Base","62.416698","-113.349998","670","NA","CA","CA-NT","","no","","","CEP9","CEP9","","https://en.wikipedia.org/wiki/Namushka_Lodge_Water_Aerodrome","EP9"
"1786","CYKD","small_airport","Aklavik/Freddie Carmichael Airport","68.223297","-135.00599","23","NA","CA","CA-NT","Aklavik","yes","CYKD","LAK","CYKD","","","https://en.wikipedia.org/wiki/Aklavik_Airport","YKD"
"28159","CES6","seaplane_base","Arctic Red River Seaplane Base","67.449997","-133.75","20","NA","CA","CA-NT","","no","","","","ES6","","https://en.wikipedia.org/wiki/Arctic_Red_River_Water_Aerodrome",""
"944","CFF4","small_airport","Great Bear Lake Airport","66.703053","-119.716816","562","NA","CA","CA-NT","Great Bear Lake","no","","DAS","CFF4","CFF4","","https://en.wikipedia.org/wiki/Great_Bear_Lake_Airport","FF4"
"1737","CYFS","medium_airport","Fort Simpson Airport","61.76020050048828","-121.23699951171875","555","NA","CA","CA-NT","Fort Simpson","yes","CYFS","YFS","CYFS","","","https://en.wikipedia.org/wiki/Fort_Simpson_Airport",""
"901","CET9","small_airport","Jean Marie River Airport","61.522449","-120.625334","470","NA","CA","CA-NT","Jean Marie River","no","","","CET9","CET9","","https://en.wikipedia.org/wiki/Jean_Marie_River_Airport","ET9"
"1940","CYVQ","medium_airport","Norman Wells Airport","65.28160095214844","-126.7979965209961","238","NA","CA","CA-NT","Norman Wells","yes","CYVQ","YVQ","CYVQ","","","https://en.wikipedia.org/wiki/Norman_Wells_Airport",""
"909","CEV9","small_airport","Snare River Airport","63.4333","-116.174963","700","NA","CA","CA-NT","Snare River","no","","","CEV9","CEV9","","https://en.wikipedia.org/wiki/Snare_River_Airport","EV9"
"1843","CYPC","small_airport","Paulatuk (Nora Aliqatchialuk Ruben) Airport","69.360838","-124.07547","15","NA","CA","CA-NT","Paulatuk","yes","CYPC","YPC","CYPC","","","https://en.wikipedia.org/wiki/Paulatuk_Airport",""
"933","CFC4","small_airport","Macmillan Pass Airport","63.181098938","-130.20199585","3810","NA","CA","CA-YT","Macmillan Pass","no","","XMP","CFC4","CFC4","","https://en.wikipedia.org/wiki/Macmillian_Pass_Airport","FC4"
"996","CFT3","small_airport","Finlayson Lake Airport","61.6913986206","-130.774002075","3094","NA","CA","CA-YT","Finlayson Lake","no","","","CFT3","CFT3","","https://en.wikipedia.org/wiki/Finlayson_Lake_Airport","FT3"
"997","CFT5","small_airport","Hyland Airport","61.52389907836914","-128.2689971923828","3000","NA","CA","CA-YT","Hyland","no","","","CFT5","CFT5","","https://en.wikipedia.org/wiki/Hyland_Airport","FT5"
"28459","CFV5","seaplane_base","Virginia Falls Seaplane Base","61.607575","-125.756272","1702","NA","CA","CA-NT","","no","","","CFV5","CFV5","","https://en.wikipedia.org/wiki/Virginia_Falls_Water_Aerodrome",""
"1021","CFY5","small_airport","Pine Lake","60.10309982299805","-130.9340057373047","3250","NA","CA","CA-YT","Pine Lake","no","","","CFY5","CFY5","","https://en.wikipedia.org/wiki/Daughney_Aerodrome","FY5"
"44373","CGK2","small_airport","Gahcho Kue","63.435198","-109.144812","1371","NA","CA","CA-NT","Kennedy Lake","no","","GHK","CGK2","CGK2","","https://en.wikipedia.org/wiki/Gahcho_Kue_Aerodrome","De Beers Canada"
"308297","CGR3","small_airport","George Lake Aerodrome","65.928056","-107.462222","1150","NA","CA","CA-NU","","no","","","CGR3","CGR3","","https://en.wikipedia.org/wiki/George_Lake_Aerodrome",""
"308296","CGS2","small_airport","Goose Lake Aerodrome","65.552778","-106.433611","907","NA","CA","CA-NU","","no","","","CGS2","CGS2","","https://en.wikipedia.org/wiki/Goose_Lake_Aerodrome",""
"308295","CHC5","small_airport","Hayes Camp Aerodrome","66.661012","-91.55346","780","NA","CA","CA-NU","","no","","","CHC5","CHC5","","https://en.wikipedia.org/wiki/Hayes_Camp_Aerodrome",""
"1685","CYCB","medium_airport","Cambridge Bay Airport","69.1081008911","-105.138000488","90","NA","CA","CA-NU","Cambridge Bay","yes","CYCB","YCB","CYCB","","","https://en.wikipedia.org/wiki/Cambridge_Bay_Airport",""
"1674","CYBK","medium_airport","Baker Lake Airport","64.29889678960001","-96.077796936","59","NA","CA","CA-NU","Baker Lake","yes","CYBK","YBK","CYBK","","","https://en.wikipedia.org/wiki/Baker_Lake_Airport",""
"28228","CJN8","seaplane_base","Fort Reliance Seaplane Base","62.7","-109.167","514","NA","CA","CA-NT","Fort Reliance","no","","YFL","CJN8","CJN8","","https://en.wikipedia.org/wiki/Fort_Reliance_Water_Aerodrome","JN8"
"1111","CJQ6","small_airport","Tanquary Fiord Airport","81.409422","-76.867901","50","NA","CA","CA-NU","Tanquary Fiord","no","","","CJQ6","CJQ6","","https://en.wikipedia.org/wiki/Tanquary_Fiord_Airport","JQ6"
"299180","CMB2","small_airport","Meadowbank Airport","65.0246615457","-96.06865882870001","475","NA","CA","CA-NU","Agnico-Eagle Meadowbank Gold Mine","no","","","CMB2","CMB2","","https://en.wikipedia.org/wiki/Meadowbank_Aerodrome",""
"1308","CNH2","small_airport","Natuashish Airport","55.913898","-61.184399","30","NA","CA","CA-NL","Natuashish","yes","","YNP","CNH2","CNH2","","https://en.wikipedia.org/wiki/Natuashish_Airport","NH2"
"1486","CRE2","small_airport","Edzo Airport","62.766701","-116.084","587","NA","CA","CA-NT","Rae-Edzo","no","","","CRE2","CRE2","","https://en.wikipedia.org/wiki/Rae/Edzo_Airport","RE2"
"1718","CYEK","small_airport","Arviat Airport","61.0942","-94.070801","32","NA","CA","CA-NU","Arviat","yes","CYEK","YEK","CYEK","","","https://en.wikipedia.org/wiki/Arviat_Airport",""
"1531","CSK6","small_airport","Snap Lake Airport","63.593601","-110.905998","1557","NA","CA","CA-NT","Snap Lake Mine","no","","YNX","","","","https://en.wikipedia.org/wiki/Snap_Lake_Airport","CSK6"
"316099","CTM7","small_airport","Tundra Mine/Salamita Mine Aerodrome","64.0725","-111.158","1445","NA","CA","CA-NT","Tundra and Salamita Mines","no","","","CTM7","CTM7","","https://en.wikipedia.org/wiki/Tundra_Mine/Salamita_Mine_Aerodrome",""
"1608","CTP9","small_airport","Donaldson Airport","61.662201","-73.321404","1902","NA","CA","CA-QC","Kattiniq","no","","YAU","CTP9","CTP9","","https://en.wikipedia.org/wiki/Kattiniq/Donaldson_Airport",""
"1712","CYDP","small_airport","Nain Airport","56.550778","-61.682224","22","NA","CA","CA-NL","Nain","yes","CYDP","YDP","CYDP","","","https://en.wikipedia.org/wiki/Nain_Airport",""
"300457","CWJC","small_airport","Ennadai Lake Airport","61.1333333333","-100.9","1021","NA","CA","CA-NU","Ennadai","no","","","CWJC","","","",""
"46596","CYAB","small_airport","Arctic Bay Airport","73.006101","-85.04616","72","NA","CA","CA-NU","Arctic Bay","yes","CYAB","YAB","CYAB","","","https://en.wikipedia.org/wiki/Arctic_Bay_Airport","CJX7"
"1659","CYAS","small_airport","Kangirsuk Airport","60.027199","-69.999199","403","NA","CA","CA-QC","Kangirsuk","yes","CYAS","YKG","CYAS","","","https://en.wikipedia.org/wiki/Kangirsuk_Airport","YAS"
"1668","CYBB","small_airport","Kugaaruk Airport","68.535711","-89.805508","56","NA","CA","CA-NU","Kugaaruk","yes","CYBB","YBB","CYBB","","","https://en.wikipedia.org/wiki/Kugaaruk_Airport","YBB, Pelly Bay Townsite Airport"
"1693","CYCO","small_airport","Kugluktuk Airport","67.816408","-115.14307","74","NA","CA","CA-NU","Kugluktuk","yes","CYCO","YCO","CYCO","","","https://en.wikipedia.org/wiki/Kugluktuk_Airport","Copper Mine Airport"
"1697","CYCS","small_airport","Chesterfield Inlet Airport","63.346901","-90.731102","32","NA","CA","CA-NU","Chesterfield Inlet","yes","CYCS","YCS","CYCS","","","https://en.wikipedia.org/wiki/Chesterfield_Inlet_Airport",""
"353838","CYD2","seaplane_base","Yellow Dog Lodge Seaplane Base","62.890556","-113.867778","725","NA","CA","CA-NT","Graham Lake","no","","","CYD2","CYD2","https://yellowdoglodge.ca/","https://en.wikipedia.org/wiki/Graham_Lake_(Yellow_Dog_Lodge)_Water_Aerodrome",""
"1708","CYDL","small_airport","Dease Lake Airport","58.422199","-130.031998","2600","NA","CA","CA-BC","Dease Lake","yes","CYDL","YDL","CYDL","","","https://en.wikipedia.org/wiki/Dease_Lake_Airport","YDL"
"1725","CYEU","small_airport","Eureka Airport","79.9946975708","-85.814201355","256","NA","CA","CA-NU","Eureka","no","CYEU","YEU","CYEU","","","https://en.wikipedia.org/wiki/Eureka_Aerodrome","YEU"
"1742","CYGH","small_airport","Fort Good Hope Airport","66.240658","-128.647757","268","NA","CA","CA-NT","Fort Good Hope","yes","CYGH","YGH","CYGH","","","https://en.wikipedia.org/wiki/Fort_Good_Hope_Airport",""
"1750","CYGT","small_airport","Igloolik Airport","69.3647","-81.816101","174","NA","CA","CA-NU","Igloolik","yes","CYGT","YGT","CYGT","","","https://en.wikipedia.org/wiki/Igloolik_Airport",""
"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",""
"1752","CYGW","medium_airport","Kuujjuarapik Airport","55.281898498535156","-77.76529693603516","34","NA","CA","CA-QC","Kuujjuarapik","yes","CYGW","YGW","CYGW","","","https://en.wikipedia.org/wiki/Kuujjuarapik_Airport",""
"1754","CYGZ","small_airport","Grise Fiord Airport","76.425823","-82.908583","146","NA","CA","CA-NU","Grise Fiord","yes","CYGZ","YGZ","CYGZ","","","https://en.wikipedia.org/wiki/Grise_Fiord_Airport",""
"1761","CYHI","small_airport","Ulukhaktok Holman Airport","70.762802","-117.806","117","NA","CA","CA-NT","Ulukhaktok","yes","CYHI","YHI","CYHI","","","https://en.wikipedia.org/wiki/Ulukhaktok/Holman_Airport",""
"1762","CYHK","small_airport","Gjoa Haven Airport","68.635597","-95.849701","152","NA","CA","CA-NU","Gjoa Haven","yes","CYHK","YHK","CYHK","","","https://en.wikipedia.org/wiki/Gjoa_Haven_Airport",""
"1765","CYHO","small_airport","Hopedale Airport","55.448757","-60.228124","39","NA","CA","CA-NL","Hopedale","yes","CYHO","YHO","CYHO","","","https://en.wikipedia.org/wiki/Hopedale_Airport","CC5"
"1774","CYIK","small_airport","Ivujivik Airport","62.417301","-77.925301","126","NA","CA","CA-QC","Ivujivik","yes","CYIK","YIK","CYIK","","","https://en.wikipedia.org/wiki/Ivujivik_Airport",""
"1775","CYIO","small_airport","Pond Inlet Airport","72.689494","-77.968941","181","NA","CA","CA-NU","Pond Inlet","yes","CYIO","YIO","CYIO","","","https://en.wikipedia.org/wiki/Pond_Inlet_Airport",""
"1788","CYKG","small_airport","Kangiqsujuaq (Wakeham Bay) Airport","61.5886","-71.929398","501","NA","CA","CA-QC","Kangiqsujuaq","yes","CYKG","YWB","CYKG","","","https://en.wikipedia.org/wiki/Kangiqsujuaq_(Wakeham_Bay)_Airport",""
"1791","CYKO","small_airport","Akulivik Airport","60.8186","-78.148598","75","NA","CA","CA-QC","Akulivik","yes","CYKO","AKV","CYKO","","","https://en.wikipedia.org/wiki/Akulivik_Airport","YKO"
"1796","CYLA","small_airport","Aupaluk Airport","59.2967","-69.599701","119","NA","CA","CA-QC","Aupaluk","yes","CYLA","YPJ","CYLA","","","https://en.wikipedia.org/wiki/Aupaluk_Airport","YLA"
"1806","CYLT","medium_airport","Alert Airport","82.517263","-62.283037","100","NA","CA","CA-NU","Alert","no","CYLT","YLT","CYLT","","","https://en.wikipedia.org/wiki/Alert_Airport","YLT"
"1818","CYMU","small_airport","Umiujaq Airport","56.536098","-76.518303","250","NA","CA","CA-QC","Umiujaq","yes","CYMU","YUD","CYMU","","","https://en.wikipedia.org/wiki/Umiujaq_Airport","YMU"
"1847","CYPH","small_airport","Inukjuak Airport","58.471901","-78.076897","83","NA","CA","CA-QC","Inukjuak","yes","CYPH","YPH","CYPH","","","https://en.wikipedia.org/wiki/Inukjuak_Airport",""
"1860","CYPX","medium_airport","Puvirnituq Airport","60.050598","-77.286903","74","NA","CA","CA-QC","Puvirnituq","yes","CYPX","YPX","CYPX","","","https://en.wikipedia.org/wiki/Puvirnituq_Airport",""
"1884","CYRA","small_airport","Rae Lakes Airport","64.116096","-117.309998","723","NA","CA","CA-NT","Gamètì","yes","CYRA","YRA","CYRA","","","https://en.wikipedia.org/wiki/Gam%C3%A8t%C3%AC/Rae_Lakes_Airport",""
"1885","CYRB","medium_airport","Resolute Bay Airport","74.7169036865","-94.9693984985","215","NA","CA","CA-NU","Resolute Bay","yes","CYRB","YRB","CYRB","","","https://en.wikipedia.org/wiki/Resolute_Bay_Airport",""
"1895","CYRT","medium_airport","Rankin Inlet Airport","62.8114013672","-92.1157989502","94","NA","CA","CA-NU","Rankin Inlet","yes","CYRT","YRT","CYRT","","","https://en.wikipedia.org/wiki/Rankin_Inlet_Airport",""
"1905","CYSK","small_airport","Sanikiluaq Airport","56.536891","-79.250221","104","NA","CA","CA-NU","Sanikiluaq","yes","CYSK","YSK","CYSK","","","https://en.wikipedia.org/wiki/Sanikiluaq_Airport",""
"1915","CYSY","small_airport","Sachs Harbour (David Nasogaluak Jr. Saaryuaq) Airport","71.993896","-125.242996","282","NA","CA","CA-NT","Sachs Harbour","yes","CYSY","YSY","CYSY","","","https://en.wikipedia.org/wiki/Sachs_Harbour_Airport",""
"1918","CYTE","small_airport","Cape Dorset Airport","64.230003","-76.526703","164","NA","CA","CA-NU","Kinngait","yes","CYTE","YTE","CYTE","","","https://en.wikipedia.org/wiki/Cape_Dorset_Airport",""
"1923","CYTQ","small_airport","Tasiujaq Airport","58.667801","-69.955803","122","NA","CA","CA-QC","Tasiujaq","yes","CYTQ","YTQ","CYTQ","","","https://en.wikipedia.org/wiki/Tasiujaq_Airport",""
"1927","CYUB","small_airport","Tuktoyaktuk / James Gruben Airport","69.433296","-133.026001","15","NA","CA","CA-NT","Tuktoyaktuk","no","CYUB","YUB","CYUB","","","https://en.wikipedia.org/wiki/Tuktoyaktuk/James_Gruben_Airport",""
"1929","CYUT","small_airport","Naujaat Airport","66.521024","-86.225166","80","NA","CA","CA-NU","Repulse Bay","yes","CYUT","YUT","CYUT","","","https://en.wikipedia.org/wiki/Repulse_Bay_Airport","Repulse Bay Airport"
"1930","CYUX","medium_airport","Hall Beach Airport","68.7761","-81.2425","30","NA","CA","CA-NU","Sanirajak","yes","CYUX","YUX","CYUX","","","https://en.wikipedia.org/wiki/Hall_Beach_Airport",""
"1939","CYVP","medium_airport","Kuujjuaq Airport","58.096099853515625","-68.4269027709961","129","NA","CA","CA-QC","Kuujjuaq","yes","CYVP","YVP","CYVP","","","https://en.wikipedia.org/wiki/Kuujjuaq_Airport",""
"954","CYWE","small_airport","Wekweètì Airport","64.190804","-114.077002","1208","NA","CA","CA-NT","Wekweètì","yes","CYWE","YFJ","CYWE","CFJ7","","https://en.wikipedia.org/wiki/Wekwe%C3%A8t%C3%AC_Airport","Wekweti, Snare Lake"
"1954","CYWY","medium_airport","Wrigley Airport","63.20940017700195","-123.43699645996094","489","NA","CA","CA-NT","Wrigley","no","CYWY","YWY","CYWY","","","https://en.wikipedia.org/wiki/Wrigley_Airport",""
"1962","CYXN","small_airport","Whale Cove Airport","62.240002","-92.598099","40","NA","CA","CA-NU","Whale Cove","yes","CYXN","YXN","CYXN","","","https://en.wikipedia.org/wiki/Whale_Cove_Airport",""
"1978","CYYH","small_airport","Taloyoak Airport","69.5467","-93.576698","92","NA","CA","CA-NU","Taloyoak","yes","CYYH","YYH","CYYH","","","https://en.wikipedia.org/wiki/Taloyoak_Airport","Spence Bay"
"1984","CYYQ","medium_airport","Churchill Airport","58.739200592041016","-94.06500244140625","94","NA","CA","CA-MB","Churchill","yes","CYYQ","YYQ","CYYQ","","","https://en.wikipedia.org/wiki/Churchill_Airport",""
"1994","CYZG","small_airport","Salluit Airport","62.179401","-75.667198","743","NA","CA","CA-QC","Salluit","yes","CYZG","YZG","CYZG","","","https://en.wikipedia.org/wiki/Salluit_Airport",""
"1998","CYZS","medium_airport","Coral Harbour Airport","64.1932983398","-83.3593978882","210","NA","CA","CA-NU","Coral Harbour","yes","CYZS","YZS","CYZS","","","https://en.wikipedia.org/wiki/Coral_Harbour_Airport",""
"2017","CZFM","small_airport","Fort Mcpherson Airport","67.406993","-134.860182","116","NA","CA","CA-NT","Fort Mcpherson","yes","CZFM","ZFM","CZFM","","","https://en.wikipedia.org/wiki/Fort_McPherson_Airport",""
"2018","CZFN","small_airport","Tulita Airport","64.909496","-125.570297","332","NA","CA","CA-NT","Tulita","yes","CZFN","ZFN","CZFN","","","https://en.wikipedia.org/wiki/Tulita_Airport","Fort Norman"
"307365","EGAH","small_airport","Halley Research Station","-75.567406","-25.516435","103","AN","AQ","AQ-U-A","Halley Research Station","no","EGAH","","EGAH","","","https://en.wikipedia.org/wiki/Halley_Research_Station","Halley, Antarctica, British Antarctic Survey"
"324046","EGAT","small_airport","Sky Blu Airstrip","-74.849722","-71.566666","4740","AN","AQ","AQ-U-A","Sky Blu","no","EGAT","","EGAT","","https://www.bas.ac.uk/polar-operations/sites-and-facilities/facility/rothera/sky-blu/","https://en.wikipedia.org/wiki/Sky_Blu",""
"31004","ENAS","small_airport","Ny-Ålesund Airport, Hamnerabben","78.927498","11.8743","50","EU","NO","NO-21","Ny-Ålesund","no","","","ENAS","","","https://en.wikipedia.org/wiki/Ny-%C3%85lesund_Airport,_Hamnerabben","Brøggerhalvøya Airport"
"2877","FHAW","medium_airport","RAF Ascension Island","-7.970179","-14.392744","278","AF","SH","SH-AC","Cat Hill","yes","FHAW","ASI","FHAW","","https://www.raf.mod.uk/our-organisation/stations/ascension-island-base/","https://en.wikipedia.org/wiki/RAF_Ascension_Island","Wideawake Airfield, Ascension Island Auxiliary Field"
"318444","FHSH","medium_airport","Saint Helena International Airport","-15.958898","-5.646066","1017","AF","SH","SH-SH","Jamestown","yes","FHSH","HLE","FHSH","","http://sthelenaairport.com/","https://en.m.wikipedia.org/wiki/Saint_Helena_Airport#",""
"44073","GL-0006","small_airport","Summit Ice Landing Strip","72.616669","-38.5","10523","NA","GL","GL-NE","Summit Camp","no","","","","","","https://en.wikipedia.org/wiki/Summit_Camp","SUMUD"
"44064","GL-0003","small_airport","NorthGRIP Airport","75.099998","-42.316666","","NA","GL","GL-NE","NorthGRIP","no","","","","","","","GIRPA, North Greenland Ice Core Project"
"505046","GL-0014","small_airport","EastGRIP Airport","75.63","-35.99","","NA","GL","GL-NE","EastGRIP","no","","","","","","","East Greenland Ice-Core Project"
"4582","HL56","medium_airport","Matan al-Sarra Air Base","21.6877","21.8309","1769","AF","LY","LY-KF","","no","","","HL56","HL56","","https://en.wikipedia.org/wiki/Maaten_al-Sarra_Air_Base",""
"19520","KCN","seaplane_base","Chernofski Harbor Seaplane Base","53.402899","-167.520272","","NA","US","US-AK","Chernofski Harbor","no","","KCN","KCN","KCN","","http://wikimapia.org/6966644/Port-of-Chernofski-Harbor",""
"26910","YBIE","medium_airport","Bedourie Airport","-24.3461","139.460007","300","OC","AU","AU-QLD","Bedourie","yes","YBIE","BEU","YBIE","","","https://en.wikipedia.org/wiki/Bedourie_Airport",""
"308030","PAUT","small_airport","Akutan Airport","54.14459","-165.604332","133","NA","US","US-AK","Akutan","yes","PAUT","KQA","PAUT","7AK","https://web.archive.org/web/20100410064944/http://dot.alaska.gov/creg/akutan/assets/Akun_ALP_Sheet-Set-1-9.pdf","https://en.wikipedia.org/wiki/Akutan_Airport","Akun Airport"
"31907","NCMH","small_airport","Manihiki Island Airport","-10.38091","-160.999424","11","OC","CK","CK-U-A","Manihiki Island","yes","NCMH","MHX","NCMH","","","https://en.wikipedia.org/wiki/Manihiki_Island_Airport","Humphrey Island, Te Fuinga o Niva"
"32184","NCPY","small_airport","Tongareva Airport","-9.01437","-158.03241","8","OC","CK","CK-U-A","Penrhyn Island","no","NCPY","PYE","NCPY","","","https://en.wikipedia.org/wiki/Tongareva_Airport",""
"4986","NTGE","medium_airport","Reao Airport","-18.46652","-136.43855","12","OC","PF","PF-U-A","Reao","no","NTGE","REA","NTGE","","","https://en.wikipedia.org/wiki/Reao_Airport",""
"4989","NTGJ","medium_airport","Totegegie Airport","-23.07990074157715","-134.88999938964844","7","OC","PF","PF-U-A","","yes","NTGJ","GMR","NTGJ","","","https://en.wikipedia.org/wiki/Totegegie_Airport",""
"32456","NTGO","small_airport","Tatakoto Airport","-17.355226","-138.44734","12","OC","PF","PF-U-A","Tatakoto","yes","NTGO","TKV","NTGO","","","https://en.wikipedia.org/wiki/Tatakoto_Airport",""
"4992","NTGP","small_airport","Puka Puka Airport","-14.80955","-138.812621","5","OC","PF","PF-U-A","","yes","NTGP","PKP","NTGP","","","https://en.wikipedia.org/wiki/Puka-Puka_Airport",""
"32177","NTGQ","small_airport","Pukaruha Airport","-18.295726","-137.016291","5","OC","PF","PF-U-A","Pukaruha","no","NTGQ","PUK","NTGQ","","","https://en.wikipedia.org/wiki/Pukarua_Airport",""
"44636","NTGS","small_airport","Marutea Airport","-21.482194900512695","-135.63705444335938","10","OC","PF","PF-U-A","Auorotini","no","","","NTGS","","","https://en.wikipedia.org/wiki/Marutea_Sud",""
"32055","NTGW","small_airport","Nukutavake Airport","-19.284613","-138.768367","17","OC","PF","PF-U-A","Nukutavake","no","NTGW","NUK","NTGW","","","https://en.wikipedia.org/wiki/Nukutavake_Airport",""
"4996","NTGY","small_airport","Tureia Airport","-20.784262","-138.56796","12","OC","PF","PF-U-A","Tureia","no","NTGY","ZTA","NTGY","","","https://en.wikipedia.org/wiki/Tureia_Airport",""
"31128","NTKH","small_airport","Fakahina Airport","-15.992259","-140.164246","3","OC","PF","PF-U-A","Fakahina","yes","NTKH","FHZ","NTKH","","","https://en.wikipedia.org/wiki/Fakahina_Airfield",""
"5001","NTMD","medium_airport","Nuku Hiva Airport","-8.7956","-140.229004","220","OC","PF","PF-U-A","Nuku Hiva","yes","NTMD","NHV","NTMD","","","https://en.wikipedia.org/wiki/Nuku_Hiva_Airport",""
"5002","NTMN","medium_airport","Hiva Oa-Atuona Airport","-9.76879024506","-139.011001587","1481","OC","PF","PF-U-A","Hiva Oa Island","yes","NTMN","AUQ","NTMN","","","https://en.wikipedia.org/wiki/Atuona_Airport","HIX"
"32499","NTMP","small_airport","Ua Pou Airport","-9.35167","-140.078003","16","OC","PF","PF-U-A","Ua Pou","yes","NTMP","UAP","NTMP","","","https://en.wikipedia.org/wiki/Ua_Pou_Airport",""
"32497","NTMU","small_airport","Ua Huka Airport","-8.936228","-139.554062","160","OC","PF","PF-U-A","Ua Huka","yes","NTMU","UAH","NTMU","","","https://en.wikipedia.org/wiki/Ua_Huka_Airport",""
"32053","NTTX","small_airport","Moruroa Airport","-21.80978","-138.813385","7","OC","PF","PF-TG","Moruroa Atoll","no","NTTX","UOA","NTTX","","","","Mururoa, Mururura, Aopuni"
"32573","NTUV","small_airport","Vahitahi Airport","-18.780697","-138.85568","9","OC","PF","PF-U-A","Vahitahi","no","NTUV","VHZ","NTUV","","","https://en.wikipedia.org/wiki/Vahitahi_Airport",""
"11","NZ12","small_airport","Palmer Station Airport","-64.774582","-64.035907","149","AN","AQ","AQ-U-A","Palmer Station","no","","","NZ0B","NZ0B","","https://en.wikipedia.org/wiki/Palmer_Station",""
"340683","NZFX","small_airport","Phoenix Airfield","-77.956389","166.766667","18","AN","AQ","AQ-U-A","McMurdo Station","no","","","NZFX","","","https://en.wikipedia.org/wiki/Phoenix_Airfield",""
"16190","PAAK","small_airport","Atka Airport","52.220299","-174.205994","57","NA","US","US-AK","Atka","yes","PAAK","AKB","PAAK","AKA","","https://en.wikipedia.org/wiki/Atka_Airport","40A"
"5354","PADU","medium_airport","Tom Madsen (Dutch Harbor) Airport","53.89881","-166.544996","22","NA","US","US-AK","Unalaska","yes","PADU","DUT","PADU","DUT","","https://en.wikipedia.org/wiki/Unalaska_Airport","Dutch Harbor, Unalaska"
"16094","PAAP","seaplane_base","Port Alexander Seaplane Base","56.246799","-134.647995","","NA","US","US-AK","Port Alexander","yes","PAAP","PTD","PAAP","AHP","","https://en.wikipedia.org/wiki/Port_Alexander_Seaplane_Base","AK15"
"5337","PABA","medium_airport","Barter Island Long Range Radar Station Airport","70.134003","-143.582001","2","NA","US","US-AK","Barter Island","yes","PABA","BTI","PABA","BTI","","https://en.wikipedia.org/wiki/Barter_Island_LRRS_Airport",""
"5342","PABR","medium_airport","Wiley Post Will Rogers Memorial Airport","71.285402","-156.766008","44","NA","US","US-AK","Utqiaġvik","yes","PABR","BRW","PABR","BRW","","https://en.wikipedia.org/wiki/Wiley_Post-Will_Rogers_Memorial_Airport","Barrow"
"9088","PACS","small_airport","Cape Sarichef Airport","54.583934","-164.905727","291","NA","US","US-AK","Cape Sarichef","no","PACS","WSF","PACS","26AK","","https://en.wikipedia.org/wiki/Cape_Sarichef_Airport",""
"16092","PADK","medium_airport","Adak Airport","51.883564","-176.642783","18","NA","US","US-AK","Adak","yes","PADK","ADK","PADK","ADK","","https://en.wikipedia.org/wiki/Adak_Airport",""
"5368","PAGM","medium_airport","Gambell Airport","63.767694","-171.733303","27","NA","US","US-AK","Gambell","yes","PAGM","GAM","PAGM","GAM","","https://en.wikipedia.org/wiki/Gambell_Airport",""
"18251","PAIW","small_airport","Wales Airport","65.622593","-168.095","22","NA","US","US-AK","Wales","yes","PAIW","WAA","PAIW","IWK","","https://en.wikipedia.org/wiki/Wales_Airport_(Alaska)",""
"19891","PAKF","small_airport","False Pass Airport","54.84751","-163.407168","20","NA","US","US-AK","False Pass","yes","PAKF","KFP","PAKF","KFP","","https://en.wikipedia.org/wiki/False_Pass_Airport",""
"17980","PAKO","small_airport","Nikolski Air Station","52.941552","-168.849478","77","NA","US","US-AK","Nikolski","yes","PAKO","IKO","PAKO","IKO","","https://en.wikipedia.org/wiki/Nikolski_Air_Station",""
"16165","PALN","small_airport","Point Lonely Short Range Radar Site Airfield","70.911118","-153.237766","17","NA","US","US-AK","Point Lonely","no","PALN","LNI","PALN","AK71","","https://en.wikipedia.org/wiki/Point_Lonely_Short_Range_Radar_Site","Lonely Air Station"
"5385","PALU","medium_airport","Cape Lisburne LRRS Airport","68.875099","-166.110001","16","NA","US","US-AK","Cape Lisburne","yes","PALU","LUR","PALU","LUR","","https://en.wikipedia.org/wiki/Cape_Lisburne_LRRS_Airport",""
"5396","PAPB","medium_airport","St George Airport","56.577345","-169.663823","125","NA","US","US-AK","St George","yes","PAPB","STG","PAPB","PBV","","https://en.wikipedia.org/wiki/St._George_Airport_(Alaska)","A8L"
"24089","PAPO","small_airport","Point Hope Airport","68.348801","-166.798996","12","NA","US","US-AK","Point Hope","yes","PAPO","PHO","PAPO","PHO","","https://en.wikipedia.org/wiki/Point_Hope_Airport",""
"5404","PASA","medium_airport","Savoonga Airport","63.686401","-170.492996","53","NA","US","US-AK","Savoonga","yes","PASA","SVA","PASA","SVA","","https://en.wikipedia.org/wiki/Savoonga_Airport",""
"5410","PASN","medium_airport","St Paul Island Airport","57.166311","-170.222555","63","NA","US","US-AK","St Paul Island","yes","PASN","SNP","PASN","SNP","","https://en.wikipedia.org/wiki/St._Paul_Island_Airport","Pribilof"
"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",""
"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",""
"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",""
"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","УЕСТ, Аеропорт Тикси"
"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","УХМП, Аэропорт Певек"
"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"
"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","Аэропорт Омсукчан"
"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#",""
"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",""
"5416","PATQ","medium_airport","Atqasuk Edward Burnell Sr Memorial Airport","70.46704","-157.436013","96","NA","US","US-AK","Atqasuk","yes","PATQ","ATK","PATQ","ATK","","https://en.wikipedia.org/wiki/Atqasuk_Edward_Burnell_Sr._Memorial_Airport","Z46"
"5419","PAVL","small_airport","Kivalina Airport","67.734565","-164.559917","13","NA","US","US-AK","Kivalina","yes","PAVL","KVL","PAVL","KVL","","https://en.wikipedia.org/wiki/Kivalina_Airport",""
"5422","PAWI","medium_airport","Wainwright Airport","70.638","-159.994995","41","NA","US","US-AK","Wainwright","yes","PAWI","AIN","PAWI","AWI","","https://en.wikipedia.org/wiki/Wainwright_Airport_(Alaska)","5WW"
"5426","PCIS","medium_airport","Canton Island Airport","-2.7681200504300003","-171.710006714","9","OC","KI","KI-P","Abariringa","no","PCIS","CIS","PCIS","","","https://en.wikipedia.org/wiki/Canton_Island_Airport","Kanton Island"
"5494","PMDY","medium_airport","Henderson Field","28.2017","-177.380997","13","OC","UM","UM-71","Sand Island","no","PMDY","MDY","PMDY","MDY","","https://en.wikipedia.org/wiki/Henderson_Field_(Midway_Atoll)",""
"5495","PPIZ","medium_airport","Point Lay LRRS Airport","69.732903","-163.005005","22","NA","US","US-AK","Point Lay","yes","PPIZ","PIZ","PPIZ","PIZ","","https://en.wikipedia.org/wiki/Point_Lay_LRRS_Airport",""
"5506","PWAK","medium_airport","Wake Island Airfield","19.282437","166.636637","14","OC","UM","UM-79","Wake Island","yes","PWAK","AWK","PWAK","AWK","","https://en.wikipedia.org/wiki/Wake_Island_Airfield",""
"5534","RJAM","small_airport","JMSDF Minami Torishima Air Base","24.2897","153.979004","22","AS","JP","JP-13","Ogasawara","no","RJAM","MUS","RJAM","","","https://en.wikipedia.org/wiki/Minami_Torishima_Airport","Marcus Island Airport"
"5535","RJAW","medium_airport","Ioto (Iwo Jima) Airbase","24.784153","141.32261","384","AS","JP","JP-13","Ogasawara","no","RJAW","IWO","RJAW","","","https://en.wikipedia.org/wiki/Central_Field_(Iwo_Jima)","Iwo Jima Airport, Central Field, Ioto, Iōtō"
"42980","UESU","medium_airport","Zyryanka Airport","65.7485","150.8889","140","AS","RU","RU-SA","Zyryanka","yes","UESU","ZKP","UESU","","","https://en.wikipedia.org/wiki/Zyryanka_Airport","УЕСУ, Аэропорт Зырянка"
"323626","RU-0472","small_airport","Lamutskoye Airport","65.538838","168.844848","601","AS","RU","RU-CHU","Lamutskoye","no","","","UHAL","UHAL","","","УХАЛ, Ламутское"
"338091","RU-0543","small_airport","Tempa Airfield","75.76713","137.576637","3","EU","RU","RU-SA","Kotelny Island","no","","","","","","","Темп"
"6456","UHMA","medium_airport","Ugolny Yuri Ryktheu Airport","64.734902","177.740997","194","AS","RU","RU-CHU","Anadyr","yes","UHMA","DYR","UHMA","","","https://en.wikipedia.org/wiki/Ugolny_Airport","УХМА, Угольный, Аnadyr Airport, Аэропорт Анадырь"
"340795","RU-0605","small_airport","Uelkal Airstrip","65.530823","-179.287263","","EU","RU","RU-CHU","Uelkal","no","","","","","","",""
"340810","RU-0616","small_airport","Taymylyr Airstrip","72.607271","121.893179","","EU","RU","RU-SA","Taymylyr","no","","","","","","",""
"341832","RU-0676","small_airport","Ayanka Airport","63.709269","167.584196","295","AS","RU","RU-KAM","Ayanka","no","","","","","","",""
"346936","RU-0727","small_airport","Khailino Airport","60.95206","166.83801","180","AS","RU","RU-KAM","Khailino","no","","","","UHPF","","","УХПФ, Хаилино"
"42940","UHPA","small_airport","Ust-Pakhachi Airport","60.556587","169.107707","10","AS","RU","RU-KAM","Ust-Pakhachi","no","UHPA","","UHPA","UHPA","","https://en.wikipedia.org/wiki/Ust-Pakhachi_Airport","УХПА, Аэропорт Усть-Пахачи, Пахачи, Pakhachi"
"346939","RU-0730","small_airport","Sredny Pakhachi Airport","60.81718","169.07555","","AS","RU","RU-KAM","Sredny Pakhachi","no","","","","","","",""
"346948","RU-0738","small_airport","Garmanda Airport","62.18334","159.08894","","AS","RU","RU-MAG","Garmanda","no","","","","","","","UEVE"
"347894","RU-0753","small_airport","Zhilinda Airport","70.15387","113.91756","489","EU","RU","RU-SA","Zhilinda","no","","","","","","",""
"347895","RU-0754","small_airport","Ebelyakh Airport","70.88319","113.58159","387","EU","RU","RU-SA","Ebelyakh","no","","","","","","",""
"347902","RU-0760","small_airport","Yukta Airport","63.38788","105.65577","680","EU","RU","RU-KYA","Yukta","no","","","","","","",""
"347903","RU-0761","small_airport","Nakanno Airport","62.89988","108.44788","816","EU","RU","RU-IRK","Nakanno","no","","","","","","",""
"347904","RU-0762","small_airport","Khamakar Airport","61.989","108.17978","889","EU","RU","RU-IRK","Khamakar","no","","","","","","",""
"601237","RU-10132","small_airport","Yagodnoye Airfield","62.54574","149.52257","1646","AS","RU","RU-MAG","Yagodnoye","no","","","UHGE","","","","УХГЕ, Ягодное,"
"602171","RU-10134","small_airport","Valunistoe (airport)","66.421687","177.582464","","AS","RU","RU-CHU","Rudnik Valunisty","no","","","","","","",""
"602186","RU-10135","small_airport","Valunistoe Mining Complex Airport","66.766633","164.319284","999","AS","RU","RU-CHU","Vesenny","no","","","","","","",""
"602304","RU-10136","small_airport","Verkhnee Penzhino Airport","64.222657","164.244866","1093","AS","RU","RU-KAM","Penzhinsky District","no","","","","","","","Верхнее Пенжино"
"609963","RU-10173","small_airport","Stadukhino Airfield","66.965897","166.82135","1905","AS","RU","RU-CHU","Stadukhino","no","","","","ZC8P","","","ЗЦ8П, Стадукхино"
"34989","RU-2510","small_airport","Greem Bell Air Base","81.15","64.33","30","EU","RU","RU-ARK","Graham Bell Island","no","","","","XLDG","","https://en.wikipedia.org/wiki/Greem_Bell","Graham Bell Airbase, Аэродром Греэм Белл, Аэродром Грээм Белл, ЬЛДГ"
"39078","SAYE","small_airport","Esperanza Base","-63.400002","-56.983299","75","AN","AQ","AQ-U-A","Fortín Sargento Cabral","no","","","SAYE","BET","","https://en.wikipedia.org/wiki/Esperanza_Base",""
"5830","SAWB","small_airport","Gustavo Marambio Airport","-64.238297","-56.630798","760","AN","AQ","AQ-U-A","Marambio Base","no","","","SAWB","MBI","","https://en.wikipedia.org/wiki/Marambio_Airport",""
"39076","SAWZ","small_airport","Matienzo Airfield","-64.976998","-60.108992","75","AN","AQ","AQ-U-A","Matienzo Base","no","","","SAWZ","BTM","","",""
"39077","SAYB","small_airport","Base Belgrano II Airport","-77.874055","-34.62616","820","AN","AQ","AQ-U-A","Base Belgrano II","no","","","SAYB","BEB","","https://en.wikipedia.org/wiki/Belgrano_II_Base",""
"6039","SCRM","small_airport","Teniente Rodolfo Marsh Martin Airport","-62.191026","-58.98669","147","AN","AQ","AQ-U-A","Villa Las Estrellas","no","SCRM","TNM","SCRM","","","https://en.wikipedia.org/wiki/Teniente_R._Marsh_Airport",""
"39080","SAYS","small_airport","San Martín Base Airfield","-68.116699","-67.099998","75","AN","AQ","AQ-U-A","San Martín Base","no","","","SAYS","BSM","","https://en.wikipedia.org/wiki/San_Martin_Base",""
"308226","SCBO","small_airport","General Bernardo O'Higgins Base Skyway","-63.344","-57.842","900","AN","AQ","AQ-U-A","General Bernardo O'Higgins Base","no","","","SCBO","","","",""
"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",""
"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",""
"603000","AQ-0016","medium_airport","Grandfather Skiway","-77.530242","-31.827393","","AN","AQ","AQ-U-A","Antartica","no","","","","","","",""
"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",""
"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"
"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"
"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",""
"6055","SEGS","medium_airport","Seymour Galapagos Ecological Airport","-0.453758","-90.2659","207","SA","EC","EC-W","Isla Baltra","yes","SEGS","GPS","SEGS","","https://ecogal.aero/en/","https://en.wikipedia.org/wiki/Seymour_Airport",""
"41131","SEII","small_airport","General Villamil Airport","-0.942628","-90.953003","35","SA","EC","EC-W","Puerto Villamil","yes","SEII","IBB","SEII","","","",""
"32508","UEBN","small_airport","Nizhneyansk Airport","71.437822","136.189701","20","AS","RU","RU-SA","Nizhneyansk","no","","","UEBN","","","","Нижнея́нск"
"335165","UEKY","small_airport","Keng-Kyuyol Airport","68.076828","145.985126","","EU","RU","RU-SA","Keng-Kyuyol","no","","","UEKY","","","",""
"335179","UEMC","small_airport","Khatyngnakh Airport","67.489845","152.575679","","EU","RU","RU-SA","","no","","","UEMC","","","",""
"44597","UERO","small_airport","Olenyok Airport","68.514999","112.480003","847","AS","RU","RU-SA","Olenyok","no","","ONK","UERO","","","https://en.wikipedia.org/wiki/Olenyok_Airport","УЕРО, Olenek Airport, Аэропорт Оленёк, Аэропорт Оленек"
"44870","UERS","medium_airport","Saskylakh Airport","71.927902","114.080002","","AS","RU","RU-SA","Saskylakh","yes","UERS","SYS","UERS","","","https://en.wikipedia.org/wiki/Saskylakh_Airport","УЕРС, Аэропорт Саскылах"
"335193","UESC","small_airport","Berezovka Airport","67.120183","156.589766","","AS","RU","RU-SA","","no","","","UESC","","","","УЕСЦ, Берёзовка"
"43667","UESG","medium_airport","Belaya Gora Airport","68.556227","146.22784","118","AS","RU","RU-SA","Belaya Gora","no","UESG","BGN","UESG","","","https://en.wikipedia.org/wiki/Belaya_Gora_Airport","УЕСГ, Аэропорт Белая Гора"
"335194","UESH","small_airport","Argakhtakh Airport","68.443333","153.362222","65","AS","RU","RU-SA","Argakhtakh","no","","","UESH","","","","УЕСХ, Аргахтах"
"316761","UESK","medium_airport","Srednekolymsk Airport","67.4805","153.7364","60","AS","RU","RU-SA","Srednekolymsk","yes","","SEK","UESK","","","https://en.wikipedia.org/wiki/Srednekolymsk_Airport","УЕСК, Среднеколымск"
"335195","UESL","small_airport","Sylgy-Ytar Airport","67.848145","154.838862","164","AS","RU","RU-SA","","no","","","UESL","","","","УЕСЛ, Сылгы-Ытар"
"32514","UESO","medium_airport","Chokurdakh Airport","70.6231","147.901993","151","AS","RU","RU-SA","Chokurdah","yes","UESO","CKH","UESO","","","https://en.wikipedia.org/wiki/Chokurdakh_Airport","Chokurdah Airport, Cokurdah Airport, Аэропорт Чокурдах"
"335196","UESR","small_airport","Tumat Airport","70.72611","139.235833","","EU","RU","RU-SA","","no","","","UESR","","","",""
"30870","UESS","medium_airport","Cherskiy Airport","68.740601","161.337997","20","AS","RU","RU-SA","Cherskiy","yes","UESS","CYX","UESS","","","https://en.wikipedia.org/wiki/Chersky_Airport","УЕСС, Chersky Airport, Аэропорт Черский"
"6450","UEST","medium_airport","Tiksi Airport","71.697700500488","128.90299987793","26","AS","RU","RU-SA","Tiksi","yes","UEST","IKS","UEST","","","https://en.wikipedia.org/wiki/Tiksi_Airport","УЕСТ, Аеропорт Тикси"
"335197","UESV","small_airport","Svatay Airport","68.057338","151.79625","75","AS","RU","RU-SA","Svatay","no","","","UESW","","","","УЕСВ, Сватай"
"335198","UESX","small_airport","Aleko-Kyuyol Airport","68.676693","151.873798","82","AS","RU","RU-SA","","no","","","UESX","","","","УЕСЬ, Алеко-Кюель"
"335200","UESZ","small_airport","Kazachye Airport","70.746294","136.179271","","EU","RU","RU-SA","","no","","","UESZ","","","",""
"323629","UHAC","small_airport","Chuvanskoye Airport","65.172163","167.952484","644","AS","RU","RU-CHU","Chuvanskoye","no","","","UHAC","UHAC","","","УХАЦ, Чуванское"
"323630","UHAW","small_airport","Vayegi Airport","64.167442","171.057887","131","AS","RU","RU-CHU","Vayegi","no","","","UHAW","UHAW","","","УХАВ, Ваеги"
"324924","UHDW","small_airport","Vankarem Airport","67.838719","-175.8494283","3","EU","RU","RU-CHU","Vankarem","no","","","UHDW","","","","УХДВ, Ванкарем"
"530668","UHEI","small_airport","Ilirney Airport","67.255278","167.949167","1165","AS","RU","RU-CHU","","no","","","UHEI","","","",""
"525055","UHEK","small_airport","Kupol Airport","66.901667","169.56","1745","AS","RU","RU-CHU","","no","","","UHEK","","","https://en.wikipedia.org/wiki/Kupol_Airport",""
"596406","UHGL","small_airport","Glukharinoye Airfield","64.999872","153.096328","977","AS","RU","RU-MAG","Glukharinoye","no","","","UHGL","","","","УХГЛ, Глухариное, Глухариный"
"6457","UHMD","medium_airport","Provideniya Bay Airport","64.378098","-173.242996","72","AS","RU","RU-CHU","Chukotka","no","UHMD","PVS","UHMD","XHMD","","https://en.wikipedia.org/wiki/Provideniya_Bay_Airport","УХМД, ЬХМД, Bukhta Provideniya Airport, Urelik Airport, Ureliki Airport, Аэропорт Бухта Провидения, Аэропорт Урелики"
"43686","UHME","small_airport","Zaliv Kresta Airport","66.358398","-179.108393","22","AS","RU","RU-CHU","Egvekinot","no","","","UHME","","","","УХМЕ, Аэропорт Залив Креста"
"44659","UHMF","medium_airport","Omsukchan Airport","62.457001","155.744995","1732","AS","RU","RU-MAG","Omsukchan","yes","UHMF","","UHMF","","","https://en.wikipedia.org/wiki/Omsukchan_Airport","Аэропорт Омсукчан"
"35040","UHMI","small_airport","Mys Shmidta Airport","68.86808","-179.374183","20","AS","RU","RU-CHU","Mys Shmidta","no","UHMI","","UHMI","","","https://en.wikipedia.org/wiki/Mys_Shmidta_Airport","УХМИ, ЬХМИ, Мыс Шмидта"
"44473","UHMK","medium_airport","Keperveem Airport","67.845001","166.139999","623","AS","RU","RU-CHU","Keperveem","yes","UHMK","KPW","UHMK","","","https://en.wikipedia.org/wiki/Keperveyem_Airport","Keperveyem Airport, Аэропорт Кепервеем"
"42925","UHML","small_airport","Lavrentiya Airport","65.580426","-170.999177","30","AS","RU","RU-CHU","Lavrentiya","no","UHML","","UHML","","","https://en.wikipedia.org/wiki/Lavrentiya_Airport","УХМЛ, Залив Лаврентия, Lavrentiya Bay"
"44610","UHMN","small_airport","Omolon Airport","65.239998","160.539993","869","AS","RU","RU-CHU","Omolon","yes","UHMN","","UHMN","","","https://en.wikipedia.org/wiki/Omolon_Airport","УХМН, Аэропорт Омолон"
"44660","UHMO","small_airport","Markovo Airport","64.665446","170.417626","75","AS","RU","RU-CHU","Markovo","yes","UHMO","KVM","UHMO","","","https://en.wikipedia.org/wiki/Markovo_Airport","Аэропорт Марково"
"6459","UHMP","medium_airport","Pevek Airport","69.783302","170.597","11","AS","RU","RU-CHU","Apapelgino","yes","UHMP","PWE","UHMP","","","https://en.wikipedia.org/wiki/Pevek_Airport","УХМП, Аэропорт Певек"
"32518","UHMR","small_airport","Beringovskiy Airport","63.02","179.292999","3","AS","RU","RU-CHU","Beringovsky","no","UHMR","","UHMR","","","https://es.wikipedia.org/wiki/Aer%C3%B3dromo_de_Beringovski","УХМР, Аэропорт Беринговский"
"42963","UHMS","medium_airport","Seymchan Airport","62.92078","152.422775","679","AS","RU","RU-MAG","Seymchan","no","UHMS","","UHMS","","","https://en.wikipedia.org/wiki/Seymchan_Airport","УХМС, Аэропорт Сеймчан"
"324631","UHMZ","small_airport","Wrangel Island Airport","70.945356","-179.5","112","EU","RU","RU-CHU","Wrangel Island","no","","","UHMZ","UHMZ","","","УХМЗ, Остров Врангеля"
"339646","UHPN","small_airport","Manily Airport","62.485962","165.338917","33","AS","RU","RU-KAM","Manily","no","","","UHPN","","","",""
"597129","UHQE","small_airport","Slautnoye Airfield","63.176423","167.978281","","AS","RU","RU-KAM","Slautnoye","no","","","UHQE","","","","УХЯЕ, Слаутное"
"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",""
"42926","UIKE","small_airport","Yerbogachen Airport","61.2750015259","108.029998779","400","AS","RU","RU-IRK","Erbogachen","no","UIKE","ERG","UIKE","","","https://en.wikipedia.org/wiki/Erbogachen_Airport","Yerbogachyon Airport, Erbogachyon Airport, Erbogachen Airport, Аэропорт Ербогачен, Аэропорт Ербогачён"
"43672","ULDW","small_airport","Varandey Airport","68.848503","58.201401","23","AS","RU","RU-NEN","Varandey","no","ULDW","VRI","ULDW","","","https://en.wikipedia.org/wiki/Varandey_Airport","УЛДВ, Varandei Airport, Аэропорт Варандей"
"315363","UODL","small_airport","Cape Chelyuskin Airfield","77.712","104.24","60","AS","RU","RU-KYA","Chelyuskin Polar Station","no","","","UODL","UODL","","","УОДЛ, Челюскин"
"35041","UODN","medium_airport","Nagurskoye Airfield","80.803207","47.663586","59","AS","RU","RU-ARK","Alexandra Land","no","UODN","","UODN","","","https://en.wikipedia.org/wiki/Nagurskoye",""
"35086","UODS","small_airport","Sredniy Island Air Base","79.52958","91.07495","26","AS","RU","RU-KYA","Khatanga","no","UODS","","UODS","XODS","","https://en.wikipedia.org/wiki/Sredny_Ostrov","УОДС, Sredniy Ostrov Air Base, Аэродром Средний Остров, ЬОДС"
"301864","US-0200","small_airport","Kogru River Airfield","70.574196","-152.259478","","NA","US","US-AK","Kogru","no","","","","A41","","",""
"340805","US-3039","small_airport","Brownlow Point Airport","69.979281","-144.834986","","NA","US","US-AK","Brownlow Point","no","","","","","","",""
"340807","US-3041","small_airport","Demarcation Bay Airfield","69.882551","-142.305616","","NA","US","US-AK","Nuvagapak Point","no","","","","","","","Nuvagapak Point"
"316465","WAN","small_airport","Waverney Airport","-25.356917","141.925593","570","OC","AU","AU-QLD","Waverney","no","","","","","","","WAN"
"354533","YABL","small_airport","Ambalindum Airport","-23.35187","134.732434","1966","OC","AU","AU-NT","Ambalindum","no","","","YABL","","","",""
"27317","YADO","small_airport","Andado Airport","-25.407772","135.290794","549","OC","AU","AU-NT","Ghan","no","","","YADO","","","",""
"38224","YALC","small_airport","Alcoota Station Airport","-22.801963","134.4036","1994","OC","AU","AU-NT","Alcoota","no","","","YALC","","","","Engawala"
"38226","YALM","small_airport","Allambie Airport","-24.265335","134.402927","","OC","AU","AU-NT","","no","","","YALM","","","",""
"38229","YAMM","small_airport","Ammaroo Airport","-21.738300323486328","135.24200439453125","","OC","AU","AU-NT","","no","","AMX","YAMM","","","",""
"27328","YAMT","small_airport","Amata Airport","-26.097363","131.202732","695","OC","AU","AU-SA","","no","YAMT","AMT","YAMT","","","https://en.wikipedia.org/wiki/Amata_Airport",""
"38230","YANJ","small_airport","Angatja Airport","-26.100000381469727","130.39999389648438","","OC","AU","AU-SA","","no","","","YANJ","","","",""
"38231","YANN","small_airport","Anningie Airport","-21.83169937133789","133.125","","OC","AU","AU-NT","","no","","","YANN","","","",""
"27332","YANW","small_airport","Annitowa Airport","-21.200017","136.451139","901","OC","AU","AU-NT","","no","","","YANW","","","",""
"27336","YARD","small_airport","Argadargada Airport","-21.676700592041016","136.67799377441406","","OC","AU","AU-NT","","no","","","YARD","","","",""
"27340","YARN","small_airport","Areyonga Airport","-24.091565","132.271228","","OC","AU","AU-NT","Mereenie","no","","","YARN","","","",""
"38232","YARP","small_airport","Arapunya Airport","-22.316699981689453","135.6999969482422","","OC","AU","AU-NT","","no","","","YARP","","","",""
"38233","YATL","small_airport","Atula Airport","-23.255344","136.388955","915","OC","AU","AU-NT","Anatye","no","","","YATL","","","",""
"26895","YAYE","medium_airport","Ayers Rock Connellan Airport","-25.185913","130.97703","1626","OC","AU","AU-NT","Yulara","yes","YAYE","AYQ","YAYE","","","https://en.wikipedia.org/wiki/Ayers_Rock_Airport","Ayers Rock, Uluru"
"26900","YBAS","medium_airport","Alice Springs Airport","-23.806588","133.903427","1789","OC","AU","AU-NT","Alice Springs","yes","YBAS","ASP","YBAS","","http://www.alicespringsairport.com.au/","https://en.wikipedia.org/wiki/Alice_Springs_Airport",""
"26907","YBDV","medium_airport","Birdsville Airport","-25.897499084472656","139.34800720214844","159","OC","AU","AU-QLD","","yes","YBDV","BVI","YBDV","","","https://en.wikipedia.org/wiki/Birdsville_Airport",""
"27369","YBEO","small_airport","Betoota Airport","-25.694791","140.738339","91","OC","AU","AU-QLD","","no","YBEO","BTX","YBEO","","","",""
"38256","YBRC","small_airport","Barrow Creek Airport","-21.542202","133.871959","","OC","AU","AU-NT","","no","","","YBRC","","","",""
"27416","YBSR","small_airport","Blackstone Airport","-25.9867000579834","128.28799438476562","","OC","AU","AU-WA","","no","","","YBSR","","","",""
"38278","YCCK","small_airport","Canteen Creek Airport","-20.650800704956055","135.5850067138672","","OC","AU","AU-NT","","no","","","YCCK","","","",""
"38293","YCNS","small_airport","Coniston Airport","-22.13330078125","132.5330047607422","","OC","AU","AU-NT","","no","","","YCNS","","","",""
"348022","YCSK","small_airport","Casey Station Skiway","-66.288056","110.7575","1178","AN","AQ","AQ-U-A","Casey Station","no","","","YCSK","","","https://en.wikipedia.org/wiki/Casey_Station",""
"38304","YCSP","small_airport","Curtin Springs Airport","-25.333921","131.759308","","OC","AU","AU-NT","","no","","","YCSP","","","",""
"38308","YCUD","small_airport","Cuddapan Airport","-25.59000015258789","141.50799560546875","","OC","AU","AU-QLD","","no","","","YCUD","","","",""
"38460","YMDS","small_airport","Macdonald Downs Airport","-22.444000244099996","135.199005127","","OC","AU","AU-NT","","no","YMDS","MNW","YMDS","","","",""
"27532","YDRI","small_airport","Durrie Airport","-25.684999465942383","140.22799682617188","58","OC","AU","AU-QLD","","no","YDRI","DRR","YDRI","","","",""
"38330","YDRL","small_airport","De Rose Hill Airport","-26.435838","133.259954","","OC","AU","AU-SA","De Rose Hill","no","","","YDRL","","","",""
"27535","YDVR","small_airport","Docker River Airport","-24.859533","129.122209","589","OC","AU","AU-NT","Kaltukatjara","no","YDVR","DKV","YDVR","","","",""
"38334","YDYD","small_airport","Derry Downs Airport","-22.08329963684082","135.33299255371094","","OC","AU","AU-NT","","no","","","YDYD","","","",""
"38338","YELK","small_airport","Elkedra Airport","-21.172500610399997","135.444000244","","OC","AU","AU-NT","","no","","EKD","YELK","","","",""
"38342","YEPR","small_airport","Epenarra Airport","-20.433300018310547","135.26699829101562","","OC","AU","AU-NT","","no","","","YEPR","","","",""
"38345","YERL","small_airport","Erldunda Airport","-25.206106","133.254161","","OC","AU","AU-NT","Ghan","no","","EDD","YERL","","","",""
"27554","YERN","small_airport","Ernabella Airport","-26.2632999420166","132.1820068359375","707","OC","AU","AU-SA","","no","YERN","ERB","YERN","","","",""
"27559","YEVP","small_airport","Everard Park Airport","-27.038988","132.688585","1615","OC","AU","AU-SA","Mimili","no","","","YEVP","","","",""
"31129","YFNE","small_airport","Finke Airport","-25.5946998596","134.582992554","","OC","AU","AU-NT","Finke","no","YFNE","FIK","YFNE","","","",""
"27566","YFRG","small_airport","Fregon Airport","-26.774999618530273","132.01699829101562","528","OC","AU","AU-SA","","no","","","YFRG","","","",""
"26985","YFRT","medium_airport","Forrest Airport","-30.83662","128.112811","511","OC","AU","AU-WA","","no","YFRT","FOS","YFRT","","","https://en.wikipedia.org/wiki/Forrest_Airport",""
"27631","YIDK","small_airport","Indulkana Airport","-26.965435","133.321989","401","OC","AU","AU-SA","Indulkana","no","YIDK","IDK","YIDK","","","",""
"27594","YGLE","small_airport","Glengyle Airport","-24.808300018310547","139.60000610351562","91","OC","AU","AU-QLD","","no","YGLE","GLG","YGLE","","","",""
"27599","YGLS","small_airport","Giles Airport","-25.043699","128.296005","579","OC","AU","AU-WA","Warakurna","no","","","YGLS","","","https://en.wikipedia.org/wiki/Giles_Airport",""
"27612","YHAA","small_airport","Haasts Bluff Airport","-23.454999923706","131.85299682617","2269","OC","AU","AU-NT","","no","","","YHAA","","","",""
"27619","YHKT","small_airport","Huckitta Airport","-22.943300247192383","135.44700622558594","","OC","AU","AU-NT","","no","","","YHKT","","","",""
"27621","YHMB","small_airport","Hermannsburg Airport","-23.926485","132.807798","593","OC","AU","AU-NT","Hermannsburg (Ntaria)","no","YHMB","HMG","YHMB","","","",""
"38390","YHSB","small_airport","Horseshoe Bend Airport","-25.216699600219727","134.26699829101562","","OC","AU","AU-NT","","no","","","YHSB","","","",""
"38394","YHTS","small_airport","Harts Range Airport","-22.985000610351562","134.91799926757812","","OC","AU","AU-NT","","no","","","YHTS","","","",""
"27632","YIDR","small_airport","Idracowra Airport","-25.06329917907715","133.73300170898438","","OC","AU","AU-NT","","no","","","YIDR","","","",""
"38396","YIKL","small_airport","Baikal Airport","-22.7632999420166","136.16299438476562","","OC","AU","AU-NT","","no","","","YIKL","","","",""
"27647","YJAM","small_airport","Jameson Airport","-25.850000381469727","127.68299865722656","","OC","AU","AU-WA","","no","","","YJAM","","","",""
"38407","YKCA","small_airport","Kings Canyon Airport","-24.260229","131.488924","2100","OC","AU","AU-NT","Petermann","no","YKCA","KBJ","YKCA","","","",""
"38425","YLBG","small_airport","Mount Liebig Airport","-23.24329948425293","131.25999450683594","","OC","AU","AU-NT","","no","","","YLBG","","","",""
"27719","YLGN","small_airport","Loongana Airport","-30.940771","127.028781","","OC","AU","AU-WA","","no","","","YLGN","","","",""
"27727","YLLC","small_airport","Lilla Creek Airport","-25.55791","134.07114","","OC","AU","AU-NT","","no","","","YLLC","","","",""
"38444","YLSY","small_airport","Mount Lindsay Airport","-27.018299102783203","129.88499450683594","","OC","AU","AU-SA","","no","","","YLSY","","","",""
"27749","YMAD","small_airport","Madura Airport","-31.887825","127.025573","","OC","AU","AU-WA","","no","","","YMAD","","","",""
"32730","YMBX","small_airport","Mundrabilla Airport","-31.86720085144043","127.85399627685547","120","OC","AU","AU-WA","Mundrabilla","no","","","YMBX","","","",""
"38455","YMBZ","small_airport","Mount Ebenezer Airport","-25.169763","132.643107","","OC","AU","AU-NT","","no","","","YMBZ","","","",""
"27763","YMDV","small_airport","Mount Davies Airport","-26.16670036315918","129.13299560546875","","OC","AU","AU-SA","","no","","","YMDV","","","",""
"27789","YMNK","small_airport","Monkira Airport","-24.816699981689453","140.5330047607422","107","OC","AU","AU-QLD","","no","YMNK","ONR","YMNK","","","",""
"27185","YYND","small_airport","Yuendumu Airport","-22.2542","131.781998","2205","OC","AU","AU-NT","","no","YYND","YUE","YYND","","","https://en.wikipedia.org/wiki/Yuendumu_Airport",""
"31943","YMQA","small_airport","Marqua Airport","-22.80579948425293","137.25100708007812","","OC","AU","AU-NT","Marqua","no","YMQA","MQE","YMQA","","","",""
"38495","YMSK","small_airport","Mount Skinner Airport","-22.187801","134.171648","","OC","AU","AU-NT","","no","","","YMSK","","","",""
"27812","YMTX","small_airport","Mount Dare Airport","-26.06170082092285","135.2469940185547","","OC","AU","AU-SA","","no","","","YMTX","","","",""
"38500","YMUP","small_airport","Mulga Park Airport","-25.860000610399997","131.649993896","","OC","AU","AU-NT","","no","YMUP","MUP","YMUP","","","",""
"27823","YMWE","small_airport","Mount Wedge Airport","-22.737199783325195","132.1510009765625","559","OC","AU","AU-NT","","no","","","YMWE","","","",""
"38507","YMYV","small_airport","Maryvale Airport","-24.647992","134.026358","","OC","AU","AU-NT","","no","","","YMYV","","","",""
"38508","YMYW","small_airport","Murray Downs Airport","-21.04829978942871","134.68299865722656","","OC","AU","AU-NT","","no","","","YMYW","","","",""
"38513","YNHV","small_airport","New Haven Airport","-22.728300094604492","131.14500427246094","","OC","AU","AU-NT","","no","","","YNHV","","","",""
"27850","YNRR","small_airport","Nyrripi Airport","-22.645299911499023","130.56500244140625","","OC","AU","AU-NT","","no","","","YNRR","","","",""
"38523","YNUE","small_airport","Numery Airport","-23.983299255371094","135.39999389648438","","OC","AU","AU-NT","","no","","","YNUE","","","",""
"38528","YNWT","small_airport","Narwietooma Airport","-23.228786","132.629378","","OC","AU","AU-NT","","no","","","YNWT","","","",""
"38535","YORA","small_airport","Moonerah Airport","-31.691413","126.590523","","OC","AU","AU-WA","","no","","","YORA","","","",""
"38537","YORT","small_airport","Ooratippra Airport","-21.905799865722656","136.06900024414062","","OC","AU","AU-NT","","no","","","YORT","","","",""
"354815","YPHS","small_airport","Pine Hill Station Airport","-22.386977","133.06025","2142","OC","AU","AU-NT","Anmatjere","no","","","YPHS","","","",""
"27924","YRNW","small_airport","Ringwood Airport","-23.82830047607422","134.96200561523438","","OC","AU","AU-NT","","no","","","YRNW","","","",""
"27991","YTIT","small_airport","Ti Tree Airport","-22.131701","133.419998","","OC","AU","AU-NT","","no","","","YTIT","","","https://en.wikipedia.org/wiki/Ti-Tree_Airfield",""
"38607","YTJU","small_airport","Tjuntjuntjarra Airport","-29.344938","127.107348","740","OC","AU","AU-WA","","no","","","YTJU","","","",""
"38609","YTLT","small_airport","Tarlton Downs Airport","-22.63330078125","136.8000030517578","","OC","AU","AU-NT","","no","","","YTLT","","","",""
"32491","YTMY","small_airport","Tobermorey Airport","-22.255800247192383","137.9530029296875","","OC","AU","AU-NT","Tobermorey","no","YTMY","TYP","YTMY","","","",""
"28014","YTYN","small_airport","Tieyon Airport","-26.216699600219727","133.91700744628906","","OC","AU","AU-SA","","no","","","YTYN","","","",""
"38623","YUMB","small_airport","Umbeara Airport","-25.746700286865234","133.70199584960938","","OC","AU","AU-NT","","no","","","YUMB","","","",""
"38625","YUTP","small_airport","Utopia Airport","-22.05500030517578","134.7899932861328","","OC","AU","AU-NT","","no","","","YUTP","","","",""
"38629","YVNS","small_airport","Vaughan Springs Airport","-22.33329963684082","130.86700439453125","","OC","AU","AU-NT","","no","","","YVNS","","","",""
"27165","YWBR","small_airport","Warburton Airport","-26.125538","126.583158","1500","OC","AU","AU-WA","Warburton","no","","","YWBR","","","https://en.wikipedia.org/wiki/Warburton_Airport",""
"44051","BGDH","small_airport","Danmarkshavn Landing Strip","76.76667","-18.666668","","NA","GL","GL-NE","Danmarkshavn","no","","","BGDH","","","",""
"302312","YWKS","small_airport","Wilkins Runway","-66.690833","111.523611","2529","AN","AQ","AQ-U-A","Preston Heath","no","","","YWKS","","","https://en.wikipedia.org/wiki/Wilkins_Runway",""
"38645","YWLA","small_airport","Willowra Airport","-21.27669906616211","132.6230010986328","","OC","AU","AU-NT","","no","","","YWLA","","","",""
"38647","YWLH","small_airport","Wallara Ranch Airport","-24.649999618530273","132.31700134277344","","OC","AU","AU-NT","","no","","","YWLH","","","",""
"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"
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
environment:
FLIGHT_TEST_HOOKS: "1"
# v0.65.0: shorten the pool refill cooldowns to seconds so e2e can watch taken slots come back;
# the job LIFETIME stays at its 1 h default on purpose — state.spec.ts restarts the container
# mid-suite and must not race a whole board expiring (the expire-package hook ages jobs instead).
FLIGHT_JOB_REFILL_MS: "5000"
FLIGHT_JOB_SMALL_REFILL_MS: "2000"
volumes:
# Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public
# before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's
+5
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
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):
// 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
+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);
});
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');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
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.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([
'/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/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/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state',
'/api/version',
]);
+31
View File
@@ -0,0 +1,31 @@
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
export async function mintPlayerSession(page: Page): Promise<string> {
const res = await page.request.post('/api/test/mint-player-session');
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
// (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']);
test('GET /api/events streams state snapshots over SSE', async () => {
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
// 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.headers.get('content-type')).toContain('text/event-stream');
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);
}
} 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) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
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
});
+21 -11
View File
@@ -1,5 +1,10 @@
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.
// 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
@@ -162,8 +167,8 @@ async function unhover(page: Page): Promise<void> {
}
// 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
// far endpoints (LEMD) off-screen even when they match a layer filter.
// features rendered inside the CURRENT viewport, and the v0.70.0 default world view ([0,0] z2) leaves
// far endpoints (e.g. NFTF) off-screen even when they match a layer filter.
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 } }>;
@@ -321,8 +326,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen
// at the default Frankfurt-centered view).
// Both endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
@@ -361,14 +365,17 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0);
});
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
// circle crosses ±180 EASTWARD. 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.
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a
// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to
// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at
// the hub; same crossing, no relocation needed.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF');
const pkg = await createPackage(page, HUB, 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
@@ -376,11 +383,14 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
await fitToAirports(page, ['NZAA', 'NFTF']);
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
// ~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 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.
await expect.poll(async () => {
+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.
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
// 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
+30 -4
View File
@@ -1,5 +1,10 @@
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"):
// expanded airport popup (name + ICAO kept, plus approximate local time and a
// deterministic generated weather), plane markers whose positions come from the
@@ -121,7 +126,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
// 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) {
return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap;
@@ -155,7 +160,23 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
{ timeout: 15_000 },
).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 map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
@@ -281,6 +302,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' })
.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
// 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
@@ -500,8 +526,8 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
await setScale(page, SCALE);
console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
// querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the
// Pacific: fit the camera over both endpoints first or the plane feature never appears in queries.
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
// Pacific leg: fit the camera over both endpoints first or the plane feature never appears in queries.
await fitToAirports(page, [ORIGIN, CROSS_DEST]);
// Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND
+63 -17
View File
@@ -2,15 +2,43 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
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 = '/';
// Read at test time so the assertion tracks package.json automatically.
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
// 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;
// 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> {
const countEl = page.getByTestId('airport-count');
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();
const count = await waitForAirportCount(page);
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 }) => {
@@ -56,18 +94,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 },
).toBeGreaterThan(0);
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
// Nantes is used because the v0.9.0 three-pane layout narrows the map column (~560 px at the
// default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays
// isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and
// 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(() => {
// Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why
// the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
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();
return { x: rect.left + p.x, y: rect.top + p.y };
});
}, LFRS);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
@@ -138,12 +173,18 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
await waitForStableZoom();
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
// Zoom out well below MIN_ZOOM with several large deltas.
await jumpTo(MIN_ZOOM + 1);
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9);
// Zoom out to the effective minimum. MapLibre won't zoom out past the point where
// the world no longer covers the viewport height (fit floor = log2(h/512)), so with
// 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();
const zoom = await getZoom();
expect(zoom).toBeCloseTo(MIN_ZOOM);
expect(zoom).toBeCloseTo(minLimit);
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
});
@@ -189,6 +230,9 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect(page.getByTestId('airport-count')).toContainText(/world airports/);
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
// 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
@@ -258,12 +302,14 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
{ timeout: 15_000 },
).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 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();
return { x: rect.left + p.x, y: rect.top + p.y };
});
}, LFRS);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
+5
View File
@@ -1,5 +1,10 @@
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.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
+5
View File
@@ -7,6 +7,11 @@ import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
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
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
+30 -2
View File
@@ -1,6 +1,11 @@
import type { Page } 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.
// 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)
@@ -12,10 +17,27 @@ import { expect, test } from '@playwright/test';
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle.
// 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 (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 };
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 {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
}
@@ -161,6 +183,9 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
{ timeout: 15_000 },
).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
// 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 }) => {
@@ -241,6 +266,9 @@ test('airport popup Relocate queues a leg for a flying plane', async ({ page })
{ timeout: 15_000 },
).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 map = (window as any).__flightMap;
const p = map.project([lon, lat]);
+17 -38
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 { 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
// 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 +
@@ -35,10 +40,12 @@ interface PlaneState {
interface PackageState {
id: string;
originIcao: string; // v0.65.0 — pool visibility assertions need the origin
destIcao: string;
originName: string;
destName: string;
reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
@@ -248,15 +255,19 @@ test('a priority package shows its special badge on the open-package row', async
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test('open jobs only appear where a plane can take them — foreign-origin jobs are hidden', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF}
const pkg = await createPackage(page, 'LEMD', HUB);
expect(pkg.originIcao).toBe('LEMD');
// v0.65.0: open jobs are only visible from airports where a plane can take them (the pool). LEMD is
// not in the pool while the starter sits at EDDF, so the job exists server-side but never reaches
// /api/state — no row and no disabled Load button to speak of.
expect((await getState(page)).packages.some((p) => p.id === pkg.id), 'a non-pool-origin open job must not appear in /api/state').toBe(false);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
});
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
@@ -441,38 +452,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);
});
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 }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
+60 -68
View File
@@ -1,4 +1,13 @@
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
// 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 WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
const STARTER = 'plane-starter';
interface StateBody {
@@ -82,7 +89,7 @@ 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);
});
test('a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
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
@@ -98,27 +105,31 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const clockBefore = before.simState.clockMs;
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to
// bound runtime either way (a zero-open snapshot would mean the bug is back).
// 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/packages/${p.id}/ignore`)).status()).toBe(200);
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
}
}
// The ~1 s background tick must top the market back up within ~5 s of real time…
// 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: 5_000, message: 'waiting for the market to refill while grounded' })
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
.toBe(true);
const after = topped!;
@@ -146,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.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
// scale or clock activation needed.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; status: string };
expect(moved.id).toBe(victim!.id);
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
await setScale(page, SCALE);
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
const after = expiredBody!;
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
} finally {
await deactivateWorld(page);
}
// Expired jobs leave the market…
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
.toBe(true);
// …and the spawner refills the pool back up — never below its target.
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
});
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
// 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 expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
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);
}
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
.toBe(true);
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
});
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 { 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
// 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.
+5
View File
@@ -1,6 +1,11 @@
import type { Page } 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
// 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
+6 -1
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
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
// 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
@@ -38,7 +43,7 @@ interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
+17
View File
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports';
import { haversineKm } from './geo';
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"';
@@ -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);
}
});
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 path from 'node:path';
import initSqlJs from 'sql.js';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
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 { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer';
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.
const LEGACY_DDL = `
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;
beforeAll(async () => {
@@ -110,8 +149,13 @@ describe('openGameDb', () => {
expect(packages.length).toBeGreaterThanOrEqual(8);
expect(packages.every((p) => p.status === 'open')).toBe(true);
const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0);
// v1.0.0 (ISS-105): the singleton economy is now a seeded local player row…
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];
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
@@ -126,25 +170,25 @@ describe('openGameDb', () => {
const file = path.join(dir, 'reopen.db');
const first = await openGameDb(file);
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.
const second = await openGameDb(file);
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
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 () => {
const file = path.join(dir, 'write.db');
const inst = await openGameDb(file);
await inst.db.update(schema.account).set({ cash: 12345 });
await updateLocalPlayerEconomy(inst, { cash: 12345 });
await persist(inst);
// A fresh instance over the same file sees the update.
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 () => {
@@ -161,8 +205,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file);
// Not re-seeded (account row exists)…
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
// Not re-seeded (the legacy account row was migrated into the local player)…
expect((await getLocalPlayer(inst)).cash).toBe(20000);
// …but the new columns exist and are usable…
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane?.departureSimTime ?? null).toBeNull();
@@ -194,8 +238,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file);
// Wallet + sim clock survive byte-identical…
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
// Wallet (migrated into the local player) + sim clock survive byte-identical…
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);
// …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);
});
// 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 () => {
const file = path.join(dir, 'pre-v12-reopen.db');
const SQL = await initSqlJs();
@@ -244,4 +359,58 @@ describe('openGameDb', () => {
expect(plane.status).toBe('flying');
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 { loadAirportsGeoJson } from '@/lib/airports';
import { buildSeed, mulberry32 } from '@/lib/game/seed';
import { LOCAL_PLAYER } from './localPlayer';
import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
@@ -22,6 +23,9 @@ export interface GameDb {
const DDL = `
CREATE TABLE IF NOT EXISTS planes (
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,
status TEXT NOT NULL,
fuel REAL NOT NULL,
@@ -47,9 +51,16 @@ CREATE TABLE IF NOT EXISTS packages (
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
created_sim_time INTEGER NOT NULL
created_sim_time INTEGER NOT NULL,
created_real_ms INTEGER NOT NULL DEFAULT 0,
slot_free_at_real_ms INTEGER
);
CREATE TABLE IF NOT EXISTS account (
-- 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,
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_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
// 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.
@@ -98,12 +147,27 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('repair_until_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
}
// v0.65.0: real-time creation stamp — legacy rows backfill to NOW so they get a fresh full lifetime
// instead of expiring on the first tick (created_real_ms = 0 is far in the past).
if (!cols('packages').has('created_real_ms')) {
raw.exec(`ALTER TABLE packages ADD COLUMN created_real_ms INTEGER NOT NULL DEFAULT ${Date.now()}`);
}
// v0.65.0: per-(origin, band) refill cooldown — nullable; legacy rows stay NULL = no cooldown.
if (!cols('packages').has('slot_free_at_real_ms')) {
raw.exec('ALTER TABLE packages ADD COLUMN slot_free_at_real_ms INTEGER');
}
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
if (!cols('sim_state').has('last_selected_time_scale')) {
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');
}
// 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
@@ -133,6 +197,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
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
// of the real ./db/flight.db.
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();
raw.exec(DDL); // no-op for tables that already exist in an older file
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
// 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')) {
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 };
// First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
// First-run seed: only when the DB is empty (no player row yet).
if (playerCount === 0 && !tables.has('account')) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
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.
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.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 persist(instance);
}
+40
View File
@@ -0,0 +1,40 @@
import { eq } 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 world-level reads with no acting session (the engine's
// shared-pool spawn quality, the SSE broadcast account) and env-gated /api/test/* admin hooks.
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));
}
+41 -4
View File
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', {
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(),
// 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'.
@@ -53,13 +57,26 @@ export const packages = sqliteTable('packages', {
deadlineSimTime: integer('deadline_sim_time').notNull(),
// '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.
// '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', 'ignored'] }).notNull(),
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
createdRealMs: integer('created_real_ms').notNull().default(0),
// Real-time epoch ms when this (origin, band) slot was last taken — loaded (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(),
reputation: real('reputation').notNull(),
});
@@ -77,7 +94,27 @@ export const simState = sqliteTable('sim_state', {
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 PackageRow = typeof packages.$inferSelect;
export type AccountRow = typeof account.$inferSelect;
export type PlayerRow = typeof players.$inferSelect;
export type SimStateRow = typeof simState.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
+2 -9
View File
@@ -38,6 +38,7 @@ const basePackage = (id: string): PackageState => ({
cargoCount: 2,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
expiresRealMs: null, // v0.65.0 — wall-clock expiry; not exercised here
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0,
status: 'open',
@@ -63,7 +64,7 @@ describe('diffSnapshots', () => {
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')] });
expect(diffSnapshots(null, next)).toEqual([]);
});
@@ -112,14 +113,6 @@ describe('diffSnapshots', () => {
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)', () => {
const bought = diffSnapshots(
snapshot(),
+4 -9
View File
@@ -15,8 +15,7 @@ export interface GameEvent {
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a
// "new package" event for every existing row would spam the stack on page load.
// No baseline yet (first snapshot after load) — nothing to diff against, so no events.
if (prev === null) return [];
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
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never
// deleted, so an id appearing is always a genuinely new one).
// Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
// (open|loaded → expired). Ids that appear for the first time emit nothing.
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id);
if (!before) {
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (!before) continue;
if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} 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 { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
@@ -21,14 +22,13 @@ import {
repRate,
SELL_FRACTION,
} from './catalog';
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
import {
abandonPackage,
buyPlane,
cancelQueuedLeg,
flyPlane,
GameActionError,
ignorePackage,
loadPackage,
queueLeg,
repairPlane,
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
const inst = await freshDb('load-ok.db');
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];
expect(row.status).toBe('loaded');
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
const inst = await freshDb(`load-${status}.db`);
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 () => {
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 () => {
const inst = await freshDb('load-ghost.db');
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 () => {
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.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 () => {
@@ -137,7 +137,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst); // originates at EDDF
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 () => {
@@ -149,7 +149,7 @@ describe('loadPackage', () => {
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 () => {
@@ -160,7 +160,7 @@ describe('loadPackage', () => {
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 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 () => {
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];
expect(plane.status).toBe('flying');
@@ -188,38 +188,38 @@ describe('flyPlane', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.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 () => {
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 () => {
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 () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
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'));
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];
expect(plane.status).toBe('available'); // still grounded…
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 () => {
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 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];
expect(plane.status).toBe('flying');
@@ -250,10 +250,10 @@ describe('flyPlane', () => {
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'));
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await updateLocalPlayerEconomy(inst, { reputation: 30 });
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);
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];
expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
const player = await getLocalPlayer(inst);
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 () => {
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
const required = await adjustedRequiredForHubToLemd();
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];
expect(plane.status).toBe('flying');
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 () => {
@@ -287,9 +287,9 @@ describe('flyPlane', () => {
const required = await adjustedRequiredForHubToLemd();
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.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];
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.
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) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
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];
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 () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
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];
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 () => {
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];
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];
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' })
.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];
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 () => {
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 () => {
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 () => {
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];
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' })
.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];
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 () => {
const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0];
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)', () => {
it('removes the leg at the given index and keeps the rest in order', async () => {
const inst = await freshDb('qcancel-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await queueLeg(inst, 'plane-starter', 'EDDV');
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
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];
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 () => {
const inst = await freshDb('qcancel-oob.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
});
it('rejects cancelling from an empty queue with 400', async () => {
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 () => {
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);
// 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 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).
if (status === 'expired') {
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 () => {
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);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned');
expect(row.planeId).toBeNull(); // slot freed
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
});
it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: 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);
});
@@ -481,31 +481,31 @@ describe('abandonPackage', () => {
const inst = await setupLoaded(name);
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);
}
});
it('is free for an already-expired package', async () => {
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((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];
expect(row.status).toBe('abandoned');
});
it('floors cash at €0 — no debt', async () => {
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((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 () => {
@@ -514,62 +514,32 @@ describe('abandonPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.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) => {
const inst = await freshDb(`ab-${status}.db`);
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 () => {
const inst = await freshDb('ab-unknown.db');
await expectActionError(abandonPackage(inst, 'nope'), 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);
await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
});
});
describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
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 { planeId } = await buyPlane(inst, 'atr72');
const { planeId } = await buyPlane(inst, 'atr72', 'local');
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const player = await getLocalPlayer(inst);
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const planes = await inst.db.select().from(schema.planes);
expect(planes.length).toBe(before + 1);
@@ -587,44 +557,44 @@ describe('buyPlane', () => {
it('rejects an unknown catalog model with 400', async () => {
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 () => {
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 () => {
const inst = await freshDb('buy-rep.db');
// 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;
await buyPlane(inst, 'atr72');
await buyPlane(inst, 'atr72', 'local');
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
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 () => {
const inst = await freshDb('buy-boundary.db');
// 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
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
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 expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
});
});
@@ -633,16 +603,16 @@ describe('sellPlane', () => {
const inst = await freshDb('sell-ok.db');
// A second plane so the starter is not "the last remaining one".
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,
});
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);
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).not.toContain('plane-extra'); // row deleted
expect(ids).toContain('plane-starter');
@@ -651,22 +621,22 @@ describe('sellPlane', () => {
it('rejects selling a flying plane with 409', async () => {
const inst = await freshDb('sell-flying.db');
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,
});
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 () => {
const inst = await freshDb('sell-cargo.db');
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,
});
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);
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 () => {
const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
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,
});
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];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
});
it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
});
it('rejects an unknown id with 404', async () => {
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 () => {
const inst = await freshDb('repair-ok.db');
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);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
});
it('is a free no-op when health is already full', async () => {
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);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100);
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 () => {
@@ -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' })
.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 () => {
@@ -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 })
.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 () => {
const inst = await freshDb('repair-blocks.db');
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 expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 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.
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,
});
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 () => {
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.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 () => {
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 { and, eq, inArray } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -33,6 +35,7 @@ import { HUB_ICAO } from './seed';
// 404 — unknown entity id
// 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)
// 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)
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 };
}
// 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}) ---
// 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
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// 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([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
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];
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') {
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`);
}
// v0.65.0: taking the job starts its slot's refill cooldown — the pool spawner holds this
// (origin, band) empty for the delay after now so the board visibly refreshes.
await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId })
.set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() })
.where(eq(schema.packages.id, packageId));
await persist(inst);
}
@@ -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
// 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.
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 plane = planeRows[0];
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') {
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.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
const player = await getPlayer(inst, playerId);
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 && player.cash < cost) {
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).
if (requiredL > target) {
@@ -171,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
})
.where(eq(schema.planes.id, planeId));
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);
}
@@ -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
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// 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 plane = planeRows[0];
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();
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
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// 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 plane = planeRows[0];
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);
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
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// 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 pkg = pkgRows[0];
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]
: undefined;
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') {
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');
const penalty = abandonPenalty(pkg, sim.clockMs);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
const player = await getPlayer(inst, playerId);
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 persist(inst);
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}) ---
// 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
// 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];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
// v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
const player = await getPlayer(inst, playerId);
// 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;
if (account.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`);
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (player.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
}
const fc = await loadAirportsGeoJson();
@@ -302,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const planeId = randomUUID();
await inst.db.insert(schema.planes).values({
id: planeId,
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
model,
status: 'available',
fuel: type.maxFuel, // full tank on delivery
@@ -313,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null,
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);
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
// 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 plane = rows[0];
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') {
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');
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
// 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];
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;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
const player = await getPlayer(inst, playerId);
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
await persist(inst);
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
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// 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 plane = rows[0];
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') {
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];
if (!sim) throw new Error('sim_state row missing');
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (account.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
const player = await getPlayer(inst, playerId);
if (player.cash < REPAIR_COST) {
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.
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.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);
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
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
// 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 {
await runAction(fn);
return Response.json(await getStateSnapshot());
await runAction((inst) => fn(inst, auth.player.id));
// 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) {
if (err instanceof GameActionError) {
return Response.json({ error: err.message }, { status: err.status });
+35 -24
View File
@@ -10,12 +10,14 @@ import {
EXPIRY_BUFFER_MS,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
generatePoolPackage,
HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
POOL_BANDS,
poolBandOf,
POOL_TARGET_PER_AIRPORT,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
@@ -65,20 +67,26 @@ describe('buildSeed', () => {
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open packages between distinct fixture airports', () => {
it('seeds a full pool of POOL_TARGET_PER_AIRPORT open packages at the hub (v0.65.0)', () => {
const seed = seedWith(42);
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
const byBand = [0, 0, 0, 0];
for (const c of seed.packages) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(c.originIcao).toBe(HUB_ICAO); // the starter's home hub — its only pool airport
const band = POOL_BANDS[poolBandOf(c.weightKg)];
byBand[poolBandOf(c.weightKg)]++;
expect(c.weightKg).toBeGreaterThanOrEqual(band.minKg); // weight stays in its band…
expect(c.weightKg).toBeLessThanOrEqual(band.maxKg); // …on both ends
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
expect(c.destIcao).not.toBe(HUB_ICAO);
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(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);
});
@@ -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;
for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0);
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
}
// v0.65.0: the heavy bands (18–80 t) deliberately exceed the starter's payload — bigger jobs are
// what you buy better planes for, so the board must always show work it can't yet do alone.
expect(seed.packages.some((c) => c.weightKg > maxPayload)).toBe(true);
}
});
@@ -171,8 +180,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
expect(a).toEqual(b);
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
@@ -186,13 +195,15 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
it('buildSeed packages are exactly the shared pool builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = [];
for (let i = 0; i < count; i++) {
// 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 })));
let n = 1;
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePoolPackage({ airports: AIRPORTS, originIcao: HUB_ICAO, bandIdx, rng: rngA, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: NOW })));
}
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
@@ -234,8 +245,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
expect(a).toEqual(b);
});
@@ -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)', () => {
// Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW });
expect(a).toEqual(b);
});
});
@@ -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));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234, createdRealMs: c.createdRealMs });
expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
@@ -323,8 +334,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
});
it('defaults to the starting reputation when omitted — byte-identical output', () => {
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION, createdRealMs: NOW });
expect(a).toEqual(b); // no bonus at the starting value
});
+113 -23
View File
@@ -63,6 +63,9 @@ export interface SeedPackage {
status: 'open';
planeId: null;
createdSimTime: number;
// Real-time (Date.now()) creation stamp (v0.65.0) — open jobs expire on the wall clock, not the sim
// clock, so this is what their lifetime is measured from.
createdRealMs: number;
}
export interface SeedData {
@@ -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.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const PACKAGE_COUNT_MIN = 8;
export const PACKAGE_COUNT_MAX = 12; // inclusive
// v0.65.0: per-airport job pools replace the global market target (v0.7.0). Each airport where a
// plane can take work keeps this fixed composition of open packages — heavier bands are rarer, so
// big jobs feel like opportunities instead of background noise.
export const POOL_BANDS = [
{ minKg: 500, maxKg: 3_999, count: 2 }, // <4 t
{ minKg: 4_000, maxKg: 17_999, count: 6 }, // 4–18 t
{ minKg: 18_000, maxKg: 48_000, count: 1 }, // 18–48 t
{ minKg: 48_001, maxKg: 80_000, count: 1 }, // >48 t
] as const;
// Market target (v0.7.0): the sim engine tops open packages back up to this
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Which pool band a package's weight falls into (v0.65.0) — the spawner counts open packages per
// band and holds taken slots per band, so this must agree with POOL_BANDS' boundaries exactly.
export function poolBandOf(weightKg: number): number {
if (weightKg < 4_000) return 0;
if (weightKg < 18_000) return 1;
if (weightKg <= 48_000) return 2;
return 3;
}
// Total open packages per pool airport — the seed builds exactly this at the starter's home hub.
export const POOL_TARGET_PER_AIRPORT = POOL_BANDS.reduce((sum, b) => sum + b.count, 0);
// v0.65.0: open jobs live on the WALL clock, not the sim clock — they expire JOB_LIFETIME_MS after
// creation regardless of time scale or fleet state (checked every tick), so a paused world still
// refreshes its board. Test hooks may shorten these via env when FLIGHT_TEST_HOOKS=1 (CI sets the
// refill delays to seconds; the lifetime stays at 1 h everywhere).
function envMs(name: string, fallback: number): number {
if (process.env.FLIGHT_TEST_HOOKS !== '1') return fallback;
const v = Number(process.env[name]);
return Number.isFinite(v) && v > 0 ? Math.round(v) : fallback;
}
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
// Refill cooldown after a job is TAKEN (loaded): the slot stays empty this long so the
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
export const SMALL_REFILL_DELAY_MS = envMs('FLIGHT_JOB_SMALL_REFILL_MS', 60_000); // 1 min wall clock
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
@@ -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
// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is
// finishable by the fastest catalog plane; short legs are byte-identical to before.
// v0.65.0: createdRealMs optionally FORCES the wall-clock creation stamp (default Date.now()) — a pure
// input consuming no RNG draws; the seed passes it explicitly so seeded worlds stay deterministic.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -176,6 +213,7 @@ export function generatePackage(input: {
weightKg?: number;
special?: string | null;
reputation?: number;
createdRealMs?: number;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -231,6 +269,9 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
// v0.65.0: a FORCED weight overrides the drawn count so display/payload math agree with weightKg —
// pool packages carry band weights far beyond the starter's payload, where the drawn count would lie.
const finalCargoCount = input.weightKg !== undefined ? Math.max(1, Math.round(input.weightKg / UNIT_WEIGHT_KG[cargoType])) : cargoCount;
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
// below AFTER the appended special draw so the fixed order above holds.
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
@@ -257,16 +298,51 @@ export function generatePackage(input: {
destIcao: dest.icao,
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType,
cargoCount,
cargoCount: finalCargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
createdSimTime: nowMs,
createdRealMs: input.createdRealMs ?? Date.now(),
};
}
// v0.65.0: one pool package — a forced origin (the pool airport), a within-cap random destination
// (same cap as generated legs, so every pool job is flyable by some catalog plane), and a weight drawn
// uniformly from the band's range. Shared by buildSeed and the runtime spawner so seeded and spawned
// packages are indistinguishable. RNG draws per package: dest, weight, then generatePackage's own order.
export function generatePoolPackage(input: {
airports: SeedAirport[];
originIcao: string;
bandIdx: number;
rng: Rng;
clockMs: number; // game time — anchors createdSimTime/deadlineSimTime
id: string;
createdRealMs?: number; // wall-clock stamp (default Date.now())
reputation?: number;
}): SeedPackage {
const { airports, originIcao, bandIdx, rng } = input;
const oi = airports.findIndex((a) => a.icao === originIcao);
if (oi < 0) throw new Error(`unknown pool airport ${originIcao}`);
const di = pickDestWithinCap(airports, oi, rng);
if (di < 0) throw new Error(`no within-cap destination from ${originIcao}`);
const band = POOL_BANDS[bandIdx];
const weightKg = Math.round(band.minKg + rng() * (band.maxKg - band.minKg));
return generatePackage({
id: input.id,
airports,
rng,
nowMs: input.clockMs,
originIcao,
destIcao: airports[di].icao,
weightKg,
createdRealMs: input.createdRealMs ?? Date.now(),
reputation: input.reputation,
});
}
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
@@ -284,19 +360,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
}
export function buildSeed(input: {
airports: SeedAirport[];
rng: Rng;
nowMs: number;
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
// v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
// it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
// caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
const type = PLANE_TYPES[STARTER_MODEL];
const hub = pickHub(airports);
const plane: SeedPlane = {
id: 'plane-starter',
return {
id,
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel, // starts full
@@ -308,14 +379,33 @@ export function buildSeed(input: {
queue: [], // no queued legs on first run (v0.25.0)
health: 100, // full health on first run (v0.39.0, ISS-069)
};
}
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
// Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these. 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.
export function buildSeed(input: {
airports: SeedAirport[];
rng: Rng;
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[] = [];
for (let i = 0; i < count; i++) {
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
let n = 1;
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
packages.push(
generatePoolPackage({ airports, originIcao: hub.icao, bandIdx, rng, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: nowMs }),
);
}
}
return {
+1 -1
View File
@@ -14,6 +14,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
cargoCount: 3,
weightKg: 900,
special: null,
expiresRealMs: status === 'open' ? 2_000_000 : null, // v0.65.0 — open jobs carry their wall-clock expiry
distanceKm: 2000,
deadlineSimTime: 1_000_000,
status,
@@ -39,7 +40,6 @@ describe('sseFrame', () => {
pkg('loaded', 'p-loaded'),
pkg('abandoned', 'p-abandoned'),
pkg('expired', 'p-expired'),
pkg('ignored', 'p-ignored'),
];
const frame = sseFrame(snapshot(all));
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
+55 -27
View File
@@ -2,9 +2,11 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client';
import { getPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
import { JOB_LIFETIME_MS } from './seed';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -49,6 +51,21 @@ export function parseIcaoQueue(raw: string | null): string[] {
}
}
// v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination
// joins the pool while airborne), or queued for it (queued legs count even while their plane is in
// the shop — the work will be waiting at its next stop). A fleet fully repairing with empty queues
// has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's
// pool spawner so both agree on exactly which airports keep open jobs.
export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set<string> {
const set = new Set<string>();
for (const p of planes) {
if (p.status === 'available') set.add(p.locationIcao);
else if (p.status === 'flying' && p.destIcao) set.add(p.destIcao);
for (const icao of parseIcaoQueue(p.queue)) set.add(icao);
}
return set;
}
export interface PackageState {
id: string;
originIcao: string;
@@ -65,10 +82,12 @@ export interface PackageState {
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
expiresRealMs: number | null;
}
export interface StateSnapshot {
@@ -87,7 +106,7 @@ export interface StateSnapshot {
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
// Terminal history (delivered/abandoned/ignored) is unbounded in the DB and never read by the
// 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.
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
@@ -95,20 +114,23 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
}
export async function getStateSnapshot(): Promise<StateSnapshot> {
const { db } = await getGameDb();
// v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
// shape as before ({cash, reputation}), sourced per-player. Callers without a session context
// (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112).
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
// 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).
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
db.select().from(schema.account),
const [player, simRows, planeRows, packageRows] = await Promise.all([
getPlayer(inst, playerId),
db.select().from(schema.simState),
db.select().from(schema.planes),
db.select().from(schema.packages),
]);
const accountRow = accountRows[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 nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
@@ -121,6 +143,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
};
// v0.65.0: pool visibility — open packages appear only where a plane can take work (poolAirports);
// every other status is always visible (on-board cargo, history). Filtered rows are absent from the
// body entirely, so clients never see jobs they could not dispatch.
const pool = poolAirports(planeRows);
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock.
const livePlane = (p: schema.PlaneRow) => {
@@ -146,7 +173,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
};
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
account: { cash: player.cash, reputation: player.reputation },
simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L,
@@ -171,22 +198,23 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
repairUntilSimTime: p.repairUntilSimTime,
};
}),
packages: packageRows.map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
})),
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
})),
};
}
+67 -11
View File
@@ -3,19 +3,24 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
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([
'/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/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/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state',
'/api/version',
]);
@@ -137,6 +142,7 @@ describe('OpenAPI spec', () => {
'destIcao',
'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'expiresRealMs', // v0.65.0 — wall-clock expiry for open jobs; null otherwise
'id',
'originIcao',
'originName',
@@ -152,7 +158,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
});
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
@@ -179,16 +185,17 @@ describe('OpenAPI spec', () => {
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();
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '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/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
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', '403', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
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.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
@@ -242,4 +249,53 @@ describe('OpenAPI spec', () => {
expect(sell.required).toEqual(['planeId']);
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');
});
});
+396 -29
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'Flight Ops API',
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.
version: pkg.version,
},
@@ -110,6 +110,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
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: {
description: 'Unknown package id or plane id',
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)',
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: {
description: 'Unknown package id',
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': {
post: {
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',
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: {
description: 'Unknown plane id',
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',
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: {
description: 'Unknown plane id',
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)',
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: {
description: 'Unknown plane id',
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)',
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: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -362,12 +361,284 @@ 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)',
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: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
// 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' } } },
},
},
@@ -521,6 +792,101 @@ export const openApiSpec = {
},
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: {
type: 'object',
properties: {
@@ -604,10 +970,10 @@ export const openApiSpec = {
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
},
required: [
'id',
@@ -624,6 +990,7 @@ export const openApiSpec = {
'status',
'planeId',
'createdSimTime',
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
],
},
},
+231 -127
View File
@@ -5,22 +5,23 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
deliverPackages,
expirePackages,
expireLoadedPackages,
expireOpenPackages,
finishRepairs,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
shouldAdvanceTime,
spawnCoveragePackages,
spawnOpenPackages,
spawnPoolPackages,
tickOnce,
WORLD_TIME_SCALE,
} from './engine';
@@ -140,41 +141,68 @@ describe('tickOnce — clock advance', () => {
});
});
describe('expirePackages', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db');
describe('expireLoadedPackages (sim clock)', () => {
it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry-loaded.db');
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
const clock = STARTING_CLOCK_MS + 100_000;
await inst.db.insert(schema.packages).values([
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: 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-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
]);
const n = await expirePackages(inst, clock);
expect(n).toBe(2); // the open and the loaded one
const n = await expireLoadedPackages(inst, clock);
expect(n).toBe(1); // only the loaded one — open jobs live on the wall clock now (v0.65.0)
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
expect(byId.get('p-open-past')).toBe('expired');
expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
});
it('a deadline exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-edge.db');
const inst = await freshDb('expiry-loaded-edge.db');
await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: 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({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
createdRealMs: T0 - JOB_LIFETIME_MS,
});
expect(await 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 () => {
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
expect(await deliverPackages(inst, clock)).toBe(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(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 () => {
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,
// one loaded for a DIFFERENT airport (stays bound for a later leg).
@@ -247,23 +275,23 @@ 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')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
const player = (await getLocalPlayer(inst));
expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // ONLY p-pay paid out
expect(player.reputation).toBeCloseTo(
Math.round((playerBefore.reputation + reputationGainFor(km)) * 100) / 100,
9,
);
// A later tick must not award again — the plane is no longer 'flying'.
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
expect(accountAfter.cash).toBe(account.cash);
expect(accountAfter.reputation).toBe(account.reputation);
const playerAfter = (await getLocalPlayer(inst));
expect(playerAfter.cash).toBe(player.cash);
expect(playerAfter.reputation).toBe(player.reputation);
});
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
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([
{ 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 },
@@ -273,17 +301,17 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
const player = (await getLocalPlayer(inst));
expect(player.cash).toBeCloseTo(playerBefore.cash + 3_500, 9); // both rewards
expect(player.reputation).toBeCloseTo(
Math.round((playerBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
9,
); // rep per delivered package
});
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 accountBefore = (await inst.db.select().from(schema.account))[0]!;
const playerBefore = (await getLocalPlayer(inst));
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 },
@@ -292,11 +320,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
const player = (await getLocalPlayer(inst));
expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
expect(player.reputation).toBeCloseTo(
Math.round((playerBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9,
);
});
@@ -354,7 +382,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
.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
expect(await deliverPackages(inst, clock)).toBe(1);
@@ -375,7 +403,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
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(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 plane = (await inst.db.select().from(schema.planes))[0];
@@ -385,21 +413,21 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
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
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost
const player = (await getLocalPlayer(inst));
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 () => {
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 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']) })
.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
expect(await deliverPackages(inst, clock)).toBe(1);
@@ -411,7 +439,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
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);
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 () => {
@@ -469,6 +497,78 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.destIcao).toBeNull();
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 = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // 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 = T0 + travelMs(km, type.speedKmh); // 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)', () => {
@@ -642,12 +742,14 @@ describe('grounded fleet freezes the world clock (#135)', () => {
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
});
it('a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)', async () => {
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen).
// 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: 'ignored' }).where(eq(schema.packages.id, p.id));
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
}
}
@@ -656,14 +758,14 @@ describe('grounded fleet freezes the world clock (#135)', () => {
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).toBeGreaterThanOrEqual(MARKET_TARGET_OPEN_PACKAGES); // …but the market is topped back up…
expect(openRows.some((p) => p.originIcao === 'EDDF')).toBe(true); // …including coverage for the grounded starter's airport
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // …but the starter's pool is refilled to its full composition…
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // …all at the grounded starter's airport
});
});
describe('spawnOpenPackages', () => {
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
const inst = await freshDb('spawn.db');
describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
it('tops each pool airport up to the POOL_BANDS composition using the shared seed builder', async () => {
const inst = await freshDb('pool.db');
const clock = T0 + 123_456;
// Leave exactly 3 open packages (the rest of the seeded set is "done").
@@ -672,16 +774,18 @@ describe('spawnOpenPackages', () => {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
}
const needed = await spawnOpenPackages(inst, clock, mulberry32(7));
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3);
const needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0);
expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // single grounded plane → one pool
const fc = await loadAirportsGeoJson();
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
for (const p of openRows.slice(3)) { // the spawned ones
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
expect(p.createdRealMs).toBe(T0); // …and stamped with the injected wall clock
expect(icaoSet.has(p.originIcao)).toBe(true);
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
@@ -693,15 +797,36 @@ describe('spawnOpenPackages', () => {
}
// Already at target → nothing more to spawn.
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
expect(await spawnPoolPackages(inst, clock, mulberry32(7), T0)).toBe(0);
});
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
const a = await freshDb('spawn-a.db');
const b = await freshDb('spawn-b.db');
it('keeps the band composition per airport — weights stay inside their band', async () => {
const inst = await freshDb('pool-composition.db');
// Wipe all seeded packages so the spawner builds the pool from zero with a known RNG.
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT); // one pool (EDDF) at full composition
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
const byBand = [0, 0, 0, 0];
for (const p of openRows) {
const b = poolBandOf(p.weightKg);
byBand[b]++;
expect(p.weightKg).toBeGreaterThanOrEqual(POOL_BANDS[b].minKg); // weight stays in its band…
expect(p.weightKg).toBeLessThanOrEqual(POOL_BANDS[b].maxKg); // …on both ends
}
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
});
it('is deterministic for an injected RNG and wall clock', async () => {
const a = await freshDb('pool-a.db');
const b = await freshDb('pool-b.db');
const clock = T0 + 999;
// Wipe all packages in both so both spawn from zero with the same RNG.
// Wipe all packages in both so both spawn from zero with the same RNG and now.
for (const inst of [a, b]) {
const rows = await inst.db.select().from(schema.packages);
for (const r of rows) {
@@ -709,8 +834,8 @@ describe('spawnOpenPackages', () => {
}
}
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const strip = (rows: schema.PackageRow[]) =>
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
@@ -718,94 +843,73 @@ describe('spawnOpenPackages', () => {
strip(await b.db.select().from(schema.packages)),
);
});
});
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
// The seeded starter plane is grounded at EDDF with no seeded package from
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
it('spawns an open package originating at the airport of each uncovered grounded plane', async () => {
const inst = await freshDb('coverage.db');
const clock = T0 + 5_000;
// Wipe all seeded packages so coverage is the only source of new ones.
it('spawns for flying destinations and queued airports, not just grounded locations', async () => {
const inst = await freshDb('pool-membership.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
const n = await spawnCoveragePackages(inst, clock, mulberry32(7));
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(1);
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
expect(openRows[0].createdSimTime).toBe(clock);
});
it('does nothing when every grounded airport already has an open package', async () => {
const inst = await freshDb('coverage-covered.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
// One open package from EDDF covers the starter plane's airport.
await inst.db.insert(schema.packages).values({
id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
// Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is
// {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it.
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
await inst.db.insert(schema.planes).values({
id: 'p-2', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
});
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0);
expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added
});
it('spawns one package per uncovered airport when planes sit at several airports', async () => {
const inst = await freshDb('coverage-multi.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
await inst.db.insert(schema.planes).values([
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null },
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null },
]);
const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7));
expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set
const origins = (await inst.db.select().from(schema.packages))
.filter((p) => p.status === 'open')
.map((p) => p.originIcao)
.sort();
expect(origins).toEqual(['EDDF', 'LEMD']);
expect(origins).toEqual(
[...Array(POOL_TARGET_PER_AIRPORT).fill('EHAM'), ...Array(POOL_TARGET_PER_AIRPORT).fill('LEMD')].sort(),
);
});
it('ignores flying planes — they cannot take new work until they land', async () => {
const inst = await freshDb('coverage-flying.db');
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
it('holds taken slots for their refill delay, then refills them', async () => {
const inst = await freshDb('pool-cooldown.db');
// Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport
// cooldown — see the ponytail note in spawnPoolPackages).
const all = await inst.db.select().from(schema.packages);
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
await inst.db.update(schema.packages).set({ status: '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 () => {
const inst = await freshDb('coverage-tick.db');
it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => {
const inst = await freshDb('pool-tick.db');
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
// grounded — 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({
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,
});
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
await tickOnce(inst);
const fromEdff = (await inst.db.select().from(schema.packages)).filter(
(p) => p.status === 'open' && p.originIcao === 'EDDF',
);
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
});
});
+112 -106
View File
@@ -1,20 +1,24 @@
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 { LOCAL_PLAYER, getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
import { broadcast } from '@/lib/sse';
import {
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
JOB_LIFETIME_MS,
POOL_BANDS,
REFILL_DELAY_MS,
SMALL_REFILL_DELAY_MS,
generatePoolPackage,
mulberry32,
pickDestWithinCap,
poolBandOf,
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
@@ -101,20 +105,21 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) {
await expirePackages(inst, clockMs);
await expireLoadedPackages(inst, clockMs);
await deliverPackages(inst, clockMs);
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets pool work too.
await finishRepairs(inst, clockMs);
}
// #135 follow-up (v0.64.0): market top-up + coverage spawn run on EVERY tick — frozen ones
// included — because while grounded nothing else creates packages but removal paths (ignore)
// still run; without this a grounded fleet could drain the market to zero and soft-lock forever.
// Both are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
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.
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
@@ -124,12 +129,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
return updated;
}
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
// occupying their slot, deliver no reward on arrival, and abandon for free).
// Delivered/abandoned packages are terminal and never flip. Returns the count.
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
// v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 —
// it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs
// expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and
// never flip. Both return the count flipped.
export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'loaded'));
let n = 0;
for (const p of rows) {
if (p.deadlineSimTime <= clockMs) {
@@ -140,6 +145,26 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n;
}
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). 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
// 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
@@ -226,13 +251,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
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.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
// v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
// multiple deliveries in one tick all count.
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
// 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;
await inst.db.update(schema.account).set({
await updatePlayerEconomy(inst, p.ownerId, {
cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
});
@@ -282,15 +307,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.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
// v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
// delivery awards above) so a top-up in this tick sees current cash.
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;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
}
}
@@ -328,68 +353,28 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
return n;
}
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
// SAME builder as the first-run seed (generatePackage), so spawned packages are
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
export async function spawnOpenPackages(
// v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
export async function spawnPoolPackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
nowMs?: number,
): Promise<number> {
const openCount = (
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
).length;
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
if (needed <= 0) return 0;
const 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([
const real = nowMs ?? Date.now();
const [planeRows, packageRows] = await Promise.all([
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 —
// they can't take new work until they land).
const groundedIcas = [
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
];
if (groundedIcas.length === 0) return 0;
const covered = new Set(openRows.map((c) => c.originIcao));
const missing = groundedIcas.filter((icao) => !covered.has(icao));
if (missing.length === 0) return 0;
const pool = poolAirports(planeRows);
if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({
@@ -399,29 +384,47 @@ export async function spawnCoveragePackages(
lon: f.properties.lon,
}));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
// v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
// ponytail: stays on the seeded 'local' row in v1.3.0 (ISS-110) — pools are SHARED world state
// (packages have no owner), so there is no single owning player whose rep should set job quality;
// per-player job quality / pools is a later multiplayer step (#112).
const player = await getLocalPlayer(inst);
let n = 0;
for (const icao of missing) {
const oi = airports.findIndex((a) => a.icao === icao);
if (oi < 0) continue; // defensive — the dataset is the source of all ICACs
// v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs —
// one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged.
const di = pickDestWithinCap(airports, oi, rand);
await inst.db.insert(schema.packages).values(
generatePackage({
id: randomUUID(),
airports,
rng: rand,
nowMs: clockMs,
originIcao: icao,
...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior
reputation: account.reputation,
}),
);
n++;
for (const originIcao of pool) {
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
const openByBand = [0, 0, 0, 0];
let blockedUntil: number | null = null;
for (const p of packageRows) {
if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue;
const b = poolBandOf(p.weightKg);
if (p.status === 'open') openByBand[b]++;
else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) {
// ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job
// refills too. Per-band tracking is a trivial change if the board feels sluggish.
blockedUntil = p.slotFreeAtRealMs;
}
}
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS;
while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) {
await inst.db.insert(schema.packages).values(
generatePoolPackage({
airports,
originIcao,
bandIdx,
rng: rand,
clockMs,
id: randomUUID(),
createdRealMs: real,
reputation: player.reputation,
}),
);
openByBand[bandIdx]++;
n++;
}
}
}
return n;
}
@@ -433,9 +436,12 @@ export async function spawnCoveragePackages(
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
// other clients too.
// v1.3.0 (ISS-110): the shared broadcast channel has no acting session — it carries the seeded
// local player's account until per-player SSE frames land (#112). Session-scoped reads (/api/state,
// /api/events connect frame, action responses) all source their own row.
async function broadcastState(): Promise<void> {
try {
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id))));
} catch (err) {
console.error('flight: state broadcast failed', err);
}
+3 -1
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { StateSnapshot } from '@/lib/game/state';
import { sessionHeaders } from './authClient';
// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from
// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
@@ -30,7 +31,8 @@ export function useGameAction(
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// v1.1.0 (ISS-107): action endpoints are session-guarded — echo the stored id.
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
+13 -1
View File
@@ -22,7 +22,19 @@ describe('fetchState', () => {
await expect(fetchState()).resolves.toEqual(SNAPSHOT);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' });
// No stored session in node → empty headers object (v1.1.0, ISS-107).
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: {} });
});
it('sends the stored session as X-Flight-Session when one exists (v1.1.0)', async () => {
vi.stubGlobal('window', {
localStorage: { getItem: (k: string) => (k === 'flight.session' ? 'sid-123' : null), removeItem: () => {} },
});
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT }));
vi.stubGlobal('fetch', fetchMock);
await fetchState();
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: { 'X-Flight-Session': 'sid-123' } });
});
it('throws with the HTTP status on a non-2xx response', async () => {
+7 -2
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
import { sessionHeaders, storedSession } from './authClient';
export interface GameStateResult {
state: StateSnapshot | null;
@@ -19,7 +20,8 @@ export interface GameStateResult {
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
export async function fetchState(): Promise<StateSnapshot> {
const res = await fetch('/api/state', { cache: 'no-store' });
// v1.1.0 (ISS-107): /api/state is session-guarded — the stored id rides along as X-Flight-Session.
const res = await fetch('/api/state', { cache: 'no-store', headers: sessionHeaders() });
if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
const snapshot = (await res.json()) as StateSnapshot;
// Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what
@@ -93,7 +95,10 @@ export function useGameState(intervalMs = 1000): GameStateResult {
let source: EventSource | null = null;
try {
source = new EventSource(SSE_URL);
// v1.1.0 (ISS-107): EventSource cannot set custom headers, so the session id rides along as a
// query param — /api/events accepts ?session= exactly for this (requireAuthSse).
const sid = storedSession();
source = new EventSource(sid ? `${SSE_URL}?session=${encodeURIComponent(sid)}` : SSE_URL);
} catch {
source = null; // no EventSource support — pure polling below
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.64.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
+23 -10
View File
@@ -3,10 +3,10 @@
//
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
// v0.61.0 (ISS-097): additionally keeps every non-large fixed-wing row that is REMOTE — no other
// airport within 1000 km of the kept set (lib/airports.ts isRemote) — so Antarctica, the high
// Arctic, Alaska, northern Canada and far islands are represented even though they carry no
// large_airport; same-station twins collapse via the same 40 km clustering.
// v0.68.0 (ISS-144): additionally keeps a sparse set of "special" remote airports — non-large
// fixed-wing rows with no large airport within 1000 km, greedily thinned to one per ~1000 km
// region (scheduled service + larger types preferred) so Antarctica, the high Arctic, Alaska,
// northern Canada and far islands are represented without over-filling empty regions.
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
//
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
@@ -141,12 +141,25 @@ for (const c of largeClusters) {
}
}
// v0.61.0 (ISS-097): "special" remote airports — every non-large fixed-wing row with no other
// airport within REMOTE_RADIUS_KM of the kept set joins the dataset, so empty regions get their
// real (small) airports instead of nothing. Same-station twins (two strips <40 km apart at one
// base) collapse via the same greedy clustering as above.
const remote = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
const { winners: remoteKept } = clusterWinners(remote);
// v0.68.0 (ISS-144): "special" remote airports — one per ~REMOTE_RADIUS_KM region. Candidates are
// the non-large fixed-wing rows with no large airport within REMOTE_RADIUS_KM (unchanged), sorted
// by priority (scheduled service, then type rank medium < small < seaplane, then numeric id) and
// accepted greedily: a candidate joins only if it is at least REMOTE_RADIUS_KM from EVERY already
// accepted special — no special airport within 1000 km of another. One per ~1000 km region, so
// central Australia / Canada edges get a handful of well-chosen strips instead of dozens; this
// subsumes the old 40 km remote clustering (deleted).
const TYPE_RANK = { medium_airport: 0, small_airport: 1, seaplane_base: 2 }; // legacy 'airport' ranks last
const candidates = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
candidates.sort(
(a, b) =>
Number(b.scheduled_service === 'yes') - Number(a.scheduled_service === 'yes') ||
(TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3) ||
Number(a.id) - Number(b.id),
);
const remoteKept = [];
for (const c of candidates) {
if (remoteKept.every((k) => haversineKm(c, k) >= REMOTE_RADIUS_KM)) remoteKept.push(c);
}
// --- Guards (fail loud before writing anything) ---
const finalRows = [...kept, ...remoteKept];