Compare commits

..
103 Commits
Author SHA1 Message Date
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
kamilkrzywda 1401a2f6eb Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s
- flyPlane 409s a broken plane (health <= 0) before any refuel math — fuel/cash untouched
- engine auto-chain skips chaining when a landing's wear breaks the plane (queue kept intact)
- finishRepairs heals along round(f x 100) each tick; flip to available at exactly 100 on gate pass
- repairPlane no longer jumps health to 100 — it starts the gradual heal
- REMOVED BROKEN_BURN_MULTIPLIER / conditionBurnMultiplier and all burn sites (unreachable now)
- OpenAPI + route comments reworded; unit tests reworked; e2e maintenance.spec.ts per issue
- v0.45.0
2026-09-21 21:17:51 +00:00
kamilkrzywda 0de0aaffd9 Merge pull request 'World airports + flyable job range (ISS-079, v0.44.0)' (#91) from feat/world-airports into master
CI / build + unit + e2e (push) Successful in 8m41s
2026-09-21 21:48:50 +02:00
kamilkrzywda 3ac7075eb0 World airport dataset + distance cap on generated jobs (ISS-079, v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 11m2s
2026-09-21 19:28:44 +00:00
kamilkrzywda 320bdf3b72 Merge pull request 'Wear 10× slower — maintenance lasts longer (ISS-085, v0.43.0)' (#87) from feat/wear-slowdown into master
CI / build + unit + e2e (push) Successful in 7m50s
2026-09-21 18:13:30 +02:00
kamilkrzywda dd7159c109 Wear 10× slower — maintenance lasts longer (ISS-085, v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 8m4s
2026-09-21 15:59:20 +00:00
kamilkrzywda 78032a2051 Merge pull request 'Auto-refuel on leg start; all manual refueling UI + endpoint removed (ISS-082, v0.42.0)' (#86) from feat/auto-refuel into master
CI / build + unit + e2e (push) Successful in 9m16s
Auto-refuel on leg start; manual refueling removed (ISS-082, v0.42.0)
2026-09-21 17:41:57 +02:00
kamilkrzywda 64da8e67bf Auto-refuel on dispatch; remove manual refueling UI/API/action (ISS-082, v0.42.0)
CI / build + unit + e2e (pull_request) Successful in 17m25s
2026-09-21 15:19:39 +00:00
kamilkrzywda b2d03846f5 Merge pull request 'Queue UI fixes: grayed duplicate routes, hover trace from landing airport, grounded queue lines (ISS-077, v0.41.0)' (#84) from feat/queue-ui-fixes into master
CI / build + unit + e2e (push) Successful in 9m18s
2026-09-21 15:44:01 +02:00
kamilkrzywda 68cfad434b Queue UI fixes: disable would-409 controls, hover trace from landing airport, grounded queues draw dotted lines (ISS-077, v0.41.0)
CI / build + unit + e2e (pull_request) Successful in 9m11s
2026-09-21 13:29:27 +00:00
kamilkrzywda 5d92b5edaa Merge pull request 'Top-bar version links to CHANGELOG; on-board rows show ICAO tags (ISS-080, ISS-081, v0.40.0)' (#83) from feat/topbar-changelog-link-icao-tags into master
CI / build + unit + e2e (push) Successful in 9m13s
2026-09-21 15:20:08 +02:00
kamilkrzywda 4a6a8592e0 Top-bar version links to CHANGELOG.md; on-board rows show ICAO tags (ISS-080+ISS-081, v0.40.0)
CI / build + unit + e2e (pull_request) Successful in 12m13s
2026-09-21 13:07:06 +00:00
kamilkrzywda b8f321dfd5 Merge pull request 'Maintenance: wear, broken burn +50%, repair (ISS-069, v0.39.0)' (#78) from feat/maintenance into master
CI / build + unit + e2e (push) Successful in 9m29s
Maintenance: wear, broken burn +50%, repair (ISS-069, v0.39.0)
2026-09-21 14:20:32 +02:00
kamilkrzywda 336ed4ee7c Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
2026-09-21 12:06:45 +00:00
kamilkrzywda 929452ca33 Merge pull request 'Reputation drives the economy: purchase/fuel discounts + better jobs (ISS-072, v0.38.0)' (#76) from feat/reputation-economy into master
CI / build + unit + e2e (push) Successful in 9m4s
2026-09-21 13:01:53 +02:00
kamilkrzywda 8daca54840 Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 9m3s
2026-09-21 10:51:22 +00:00
kamilkrzywda 0f5f3ae23e Merge pull request 'Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)' (#75) from feat/special-jobs into master
CI / build + unit + e2e (push) Successful in 9m6s
Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)
2026-09-21 12:17:42 +02:00
kamilkrzywda 8ec485b93e Harden right-pane/slow-on-landing e2e self-heal: clear leftover cargo, refuel-and-retry repositioning
CI / build + unit + e2e (pull_request) Successful in 9m4s
2026-09-21 10:08:33 +00:00
kamilkrzywda ea740fc378 Special jobs: data-driven special-status registry with priority status (ISS-073, v0.37.0)
CI / build + unit + e2e (pull_request) Failing after 7m56s
2026-09-21 09:30:36 +00:00
kamilkrzywda 718e3a0177 Merge pull request 'Refuel-for-flight buys 150% of requirement; wind adjusts per-leg burn ±20% (ISS-071, ISS-070, v0.36.0)' (#74) from feat/weather-fuel-burn into master
CI / build + unit + e2e (push) Failing after 12m14s
Merge feat/weather-fuel-burn: refuel ×1.5 + wind burn ±20% (ISS-071, ISS-070, v0.36.0)
2026-09-21 11:03:03 +02:00
kamilkrzywda 5030fcd8b4 Wind-adjusted fuel burn ±20% + refuel-for-flight buys 150% margin (ISS-070, ISS-071, v0.36.0)
CI / build + unit + e2e (pull_request) Successful in 10m34s
2026-09-21 08:47:42 +00:00
kamilkrzywda 6d8e1ad35e Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
CI / build + unit + e2e (push) Successful in 8m24s
Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
2026-09-21 04:58:30 +02:00
kamilkrzywda 0ac133f750 fix(e2e): hook package weight within starter payload (4 t < 4.8 t max)
CI / build + unit + e2e (pull_request) Successful in 8m21s
2026-09-21 02:50:10 +00:00
kamilkrzywda d3038b3c7f Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)
CI / build + unit + e2e (pull_request) Failing after 10m9s
2026-09-21 02:34:20 +00:00
kamilkrzywda 847e74b7b8 Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
CI / build + unit + e2e (push) Successful in 8m26s
Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
2026-09-21 04:31:54 +02:00
kamilkrzywda 394d51d827 Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)
CI / build + unit + e2e (pull_request) Successful in 8m43s
2026-09-21 01:28:41 +00:00
kamilkrzywda 264778522c Merge pull request 'Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)' (#64) from feat/merge-onboard-into-fleet into master
CI / build + unit + e2e (push) Successful in 10m53s
2026-09-21 03:16:40 +02:00
kamilkrzywda e55078fb5d Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
2026-09-21 00:46:12 +00:00
kamilkrzywda 4e585cbed1 Merge pull request 'Queue via fly-to + Relocate; remove dedicated queue input (ISS-053, v0.32.0)' (#63) from feat/queue-via-fly-to-relocate into master
CI / build + unit + e2e (push) Successful in 9m28s
2026-09-21 02:07:45 +02:00
kamilkrzywda 89ad09a04d Queue via fly-to + Relocate; remove dedicated queue-add input (ISS-053, v0.32.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
- LeftPane fly-to button enabled for any plane state; posts to /queue when the plane is flying, else /fly
- Relocate lists ALL planes (relocatable); relocate-go posts to /queue for a flying selected plane, else /fly
- Delete queue-add select + Add-to-queue button and shared queueAddDestIcao state; leg rows/cancel kept
- e2e/queue.spec.ts test 1 adds its queued leg via the Relocate section (starter is flying)
- e2e/two-planes.spec.ts per-plane-queues test adds the starter's second leg via API POST /queue
2026-09-20 23:48:12 +00:00
kamilkrzywda bf2c46b959 Merge pull request 'Two-plane load fix + e2e coverage for 2-plane gameplay (ISS-055, v0.31.0)' (#62) from fix/two-planes-load-and-e2e into master
CI / build + unit + e2e (push) Successful in 9m42s
2026-09-21 01:08:55 +02:00
kamilkrzywda fa64e32f65 Fix two-planes e2e healPlaneHome: restore 1000× before waiting for landing (ISS-057 slows sim to 1×)
CI / build + unit + e2e (pull_request) Successful in 9m21s
2026-09-20 22:59:29 +00:00
kamilkrzywda 04b6f20a93 Fix two-planes e2e fuel 409s: refuel before return leg; healPlaneHome retries after refuel (ISS-055)
CI / build + unit + e2e (pull_request) Failing after 17m3s
2026-09-20 22:15:27 +00:00
kamilkrzywda acee806180 Fix two-planes.spec.ts load test: switch back to Jobs tab after buying via UI (tabs unmount inactive content)
CI / build + unit + e2e (pull_request) Failing after 8m26s
2026-09-20 21:56:00 +00:00
kamilkrzywda 1e3992f872 Two-plane load fix + e2e coverage (ISS-055, v0.31.0)
CI / build + unit + e2e (pull_request) Failing after 8m13s
- LoadControls preselects the first eligible plane so Load is one-click with N planes
- LeftPane queue testids scoped by plane id (queue-leg/cancel/add[-dest]-{plane.id})
  — unscoped ids duplicated in the DOM with two on-board blocks
- new POST /api/test/add-cash hook (FLIGHT_TEST_HOOKS=1, default €1M) so e2e can
  afford a second plane; returns the full state snapshot
- new serial spec e2e/two-planes.spec.ts: load, parallel fly + ISS-057 slow/resume,
  refuel, relocate, per-plane queues with auto-chain, sell (6 tests)
- e2e/queue.spec.ts locators updated to the scoped testids
2026-09-20 21:26:16 +00:00
kamilkrzywda 57cc6705f2 Merge pull request 'Slow to 1× on landing + resume last selected speed (ISS-057, v0.30.0)' (#61) from feat/slow-resume-time-scale into master
CI / build + unit + e2e (push) Successful in 8m18s
2026-09-20 22:43:27 +02:00
kamilkrzywda c9e9113a45 Slow sim to 1× on landing and resume last selected speed (ISS-057, v0.30.0)
CI / build + unit + e2e (pull_request) Successful in 8m57s
2026-09-20 20:27:59 +00:00
kamilkrzywda 5e7ec903d3 Merge pull request 'Remove flaky time-controls e2e test (v0.29.1)' (#60) from fix/remove-flaky-time-controls-test into master
CI / build + unit + e2e (push) Successful in 9m31s
2026-09-20 20:36:14 +02:00
kamilkrzywda 0b946b743c Remove flaky time-controls e2e test (v0.29.1)
CI / build + unit + e2e (pull_request) Successful in 8m19s
2026-09-20 18:23:20 +00:00
kamilkrzywda 86d486fb69 Merge pull request 'Ignore job: dismiss open jobs from the market (ISS-047 part B, v0.29.0)' (#59) from feat/ignore-job into master
CI / build + unit + e2e (push) Failing after 8m0s
2026-09-20 20:15:41 +02:00
kamilkrzywda b4b25551ff Fix ignore route slug collision ([packageId] → [id]) — Next.js runtime 500s (ISS-047 part B)
CI / build + unit + e2e (pull_request) Successful in 8m21s
2026-09-20 18:07:18 +00:00
kamilkrzywda 8a12f07703 Ignore a job: dismiss an open package so it leaves the market (ISS-047 part B, v0.29.0)
CI / build + unit + e2e (pull_request) Failing after 12m34s
New terminal status 'ignored' + ignorePackage action + POST /api/packages/{packageId}/ignore
(no body, no penalty; top-up logic refills the market). Ignore button per open-package row in
the Jobs tab. OpenAPI gains the 13th endpoint; unit tests for the action (happy/409x5/404),
engine terminal-status coverage, and a new e2e spec test.
2026-09-20 14:28:47 +00:00
kamilkrzywda a84bc7ed21 Merge pull request 'Right pane tabs (Jobs | Planes market) + 5 new plane types with tech-spec panels (ISS-047 part A, v0.28.0)' (#58) from feat/right-pane-tabs-plane-market into master
CI / build + unit + e2e (push) Failing after 9m20s
2026-09-20 16:11:34 +02:00
kamilkrzywda 7524f943d2 Right pane tabs Jobs/Planes market + 5 new plane types with tech-spec panels + sell-plane cargo guard (ISS-047 part A)
CI / build + unit + e2e (pull_request) Successful in 8m56s
2026-09-20 13:57:52 +00:00
kamilkrzywda 4f182a22b6 Merge pull request 'Right pane width matches left pane: 280→360px + toast offset (ISS-046)' (#56) from ui/right-pane-width into master
CI / build + unit + e2e (push) Successful in 7m56s
2026-09-20 15:29:41 +02:00
kamilkrzywda 34b38dd5d5 Right pane width matches left pane: 280→360px + toast offset (ISS-046)
CI / build + unit + e2e (pull_request) Successful in 8m8s
2026-09-20 13:17:33 +00:00
kamilkrzywda b2133fd25c Merge pull request 'Weight-based cargo capacity + weight/distance payout (ISS-045)' (#52) from feat/weight-capacity-payout into master
CI / build + unit + e2e (push) Successful in 7m52s
2026-09-20 14:54:54 +02:00
kamilkrzywda 179fad450d Package weight, payload capacity and per-kg payout (ISS-045)
CI / build + unit + e2e (pull_request) Successful in 8m2s
Packages carry a real weightKg (unit weight x count with +/-30% jitter),
plane capacity becomes maxPayloadKg instead of 6 slots, load validation
sums cargo weight against the payload, and the payout gains a per-kg part.
Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for
maxPayloadKg and adds PackageState.weightKg.
2026-09-20 12:42:03 +00:00
kamilkrzywda 85b7b07f53 Merge pull request 'Flight queue UI: dotted map line, left-pane Queue sub-section, e2e (ISS-043 part 2)' (#51) from feat/flight-queue-ui into master
CI / build + unit + e2e (push) Successful in 9m9s
2026-09-20 13:57:39 +02:00
kamilkrzywda 5ef661a06f Fix queue.spec.ts test 1: refuel to full before the long leg (fuel 409 was timing out the fly poll)
CI / build + unit + e2e (pull_request) Successful in 9m0s
2026-09-20 11:48:36 +00:00
kamilkrzywda 408cd933f4 Flight queue UI: dotted map line, left-pane Queue sub-section, e2e (ISS-043 part 2)
CI / build + unit + e2e (pull_request) Failing after 6m51s
2026-09-20 11:31:11 +00:00
kamilkrzywda 3395fb4f90 Merge pull request 'ISS-043 (part 1): flight queue backend — data model, auto-chain on landing, add/cancel endpoints' (#50) from feat/flight-queue-backend into master
CI / build + unit + e2e (push) Successful in 6m21s
2026-09-20 13:04:17 +02:00
kamilkrzywda 537c494daa ISS-043: add queue endpoints to e2e OpenAPI path list
CI / build + unit + e2e (pull_request) Successful in 6m17s
2026-09-20 10:57:57 +00:00
kamilkrzywda 69b708dad3 Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1)
CI / build + unit + e2e (pull_request) Failing after 6m37s
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
  leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
  migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
  covering the first queued leg (same requiredFuelFor rule as /fly), the plane
  immediately starts that leg from the arrival airport (stays 'flying',
  departureSimTime = arrival instant, queue shortened). Short tank → grounds
  safely with the queue kept intact; empty queue → unchanged grounding. Cargo
  delivery for the reached airport runs first and is unaffected. Ordering kept:
  delivery still runs before tickOnce's pause check, so a chained plane counts
  as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
  (append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
  plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
  by index; active in-flight leg never affected; 400 non-integer/out-of-range,
  404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
  shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
  CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
  queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
  blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
  right-pane, mapData and seed tests.

v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
2026-09-20 10:39:00 +00:00
kamilkrzywda e109e003c0 Merge pull request 'ISS-041: pause on landing (keep speed) + auto-resume when a flight starts' (#49) from fix/pause-on-landing-auto-unpause into master
CI / build + unit + e2e (push) Successful in 7m51s
2026-09-20 12:00:02 +02:00
kamilkrzywda cbe0cda15d Pause on landing + auto-unpause on dispatch (ISS-041)
CI / build + unit + e2e (pull_request) Successful in 7m43s
Replace the ISS-011 auto-slow with pause-on-landing: when the last plane
in flight lands, tickOnce sets sim_state.paused=true WITHOUT touching
timeScale (speed preserved). flyPlane() now clears paused before
persisting so starting a flight resumes a paused sim — inside runAction(),
tick + unpause + dispatch are atomic under the sim lock.

Tests: engine.test.ts auto-slow block rewritten for pause semantics; new
flyPlane test asserts dispatch clears paused with speed untouched. E2E:
auto-slow.spec.ts renamed to pause-on-landing.spec.ts asserting the new
behavior (paused=true AND timeScale still 1000 after landing; /fly clears
paused). Cross-cutting fix: every per-spec setScale helper now POSTs
{timeScale, paused:false} — a scale-only fast-forward after a landing would
otherwise leave the clock frozen. Deliberate mid-test pauses untouched.
2026-09-20 09:48:56 +00:00
kamilkrzywda 4620707abc Merge pull request 'ISS-040: on-board package hover traces from carrier's current position' (#48) from fix/onboard-hover-current-location into master
CI / build + unit + e2e (push) Successful in 7m6s
2026-09-20 11:24:33 +02:00
kamilkrzywda 3024606ac8 On-board package hover traces from the carrier's current position (ISS-040)
CI / build + unit + e2e (pull_request) Successful in 7m11s
2026-09-20 09:16:06 +00:00
kamilkrzywda f44e7aa0f5 Merge pull request 'v0.23.1 — Align toast stack to map right edge, clear of right pane (#39)' (#44) from ui/toast-align-map-right into master
CI / build + unit + e2e (push) Successful in 6m14s
2026-09-20 10:52:40 +02:00
kamilkrzywda 58d05f5287 Align toast stack to map right edge, clear of right pane (#39)
CI / build + unit + e2e (pull_request) Successful in 7m1s
2026-09-20 08:42:58 +00:00
kamilkrzywda e18d39edd0 Merge pull request 'v0.23.0 — Move Refuel-to-full from market into left pane next to Relocate (#35)' (#42) from ui/move-refuel-to-left-pane into master
CI / build + unit + e2e (push) Successful in 7m58s
2026-09-20 10:34:43 +02:00
kamilkrzywda a7150a1062 Move Refuel-to-full from market into left pane next to Relocate (#35)
CI / build + unit + e2e (pull_request) Successful in 6m52s
2026-09-20 08:25:57 +00:00
kamilkrzywda 8eeef401ae Merge pull request 'v0.22.2 — commit ORCHESTRATOR.md manual' (#38) from docs/orchestrator-md-v2 into master
CI / build + unit + e2e (push) Successful in 6m9s
2026-09-20 09:57:56 +02:00
kamilkrzywda f273d385c2 docs: make ORCHESTRATOR.md durable — drop specifics that rot (test counts, CI run numbers, paint values)
CI / build + unit + e2e (pull_request) Successful in 6m29s
2026-09-20 07:50:11 +00:00
kamilkrzywda 3b02c7feac docs: document GITEA_TOKEN in .env.example
CI / build + unit + e2e (pull_request) Failing after 7m17s
2026-09-20 07:41:55 +00:00
kamilkrzywda 1ba70ebbf3 docs: commit ORCHESTRATOR.md manual (token via gitignored .env)
CI / build + unit + e2e (pull_request) Successful in 6m32s
2026-09-20 07:34:04 +00:00
kamilkrzywda 08a819e44c Merge pull request 'v0.22.1 — gitignore local-only ORCHESTRATOR.md' (#37) from fix/gitignore-local-docs into master
CI / build + unit + e2e (push) Successful in 6m27s
2026-09-20 09:06:59 +02:00
kamilkrzywda 50a9147665 chore: gitignore local-only ORCHESTRATOR.md manual
CI / build + unit + e2e (pull_request) Successful in 6m30s
2026-09-20 06:58:42 +00:00
kamilkrzywda 5391636979 Merge pull request 'v0.22.0 — ISS-022/025/027 hover route trace (job list, on-board rows, relocation panel)' (#34) from feat/hover-trace into master
CI / build + unit + e2e (push) Failing after 7m10s
2026-09-20 03:58:03 +02:00
kamilkrzywda ab79d9b17f fix(e2e): renderedFeatures — evaluate maplibre geometry getter in-page before Playwright serialization drops it
CI / build + unit + e2e (pull_request) Successful in 7m28s
2026-09-20 01:50:23 +00:00
kamilkrzywda 505a58b4fe fix(e2e): expectHoverRoute — ignore queryRenderedFeatures artifacts without LineString geometry (CI run #70)
CI / build + unit + e2e (pull_request) Failing after 7m49s
2026-09-20 01:22:23 +00:00
kamilkrzywda 2655ef81c8 fix: hover-route line not drawn in e2e — MapLibre clips queried lines across tiles and quantizes their coordinates (strict single-feature equality never matched)
CI / build + unit + e2e (pull_request) Failing after 6m44s
2026-09-19 22:52:12 +00:00
kamilkrzywda ac4e3fa316 feat: v0.22.0 — ISS-022 target airport distinct-color ring on job-list / relocation hover
CI / build + unit + e2e (pull_request) Failing after 8m51s
2026-09-19 22:01:02 +00:00
kamilkrzywda f2dbad5e03 feat: v0.22.0 — ISS-022/025/027 hover route trace on job list, on-board rows and relocation panel 2026-09-19 21:47:00 +00:00
kamilkrzywda 096a6bfe61 Merge pull request 'v0.21.0 — ISS-023 Relocate (empty flight) panel moves to left pane' (#33) from feat/relocate-left-pane into master
CI / build + unit + e2e (push) Successful in 5m52s
2026-09-19 23:14:42 +02:00
kamilkrzywda 71ee63b38a feat: v0.21.0 — ISS-023 Relocate panel moves to left pane
CI / build + unit + e2e (pull_request) Successful in 5m54s
2026-09-19 21:02:55 +00:00
kamilkrzywda a4f7947666 Merge pull request 'feat: v0.20.0 — ISS-021 move On board to left pane, widen left 360/280' (#31) from feat/onboard-left-pane into master
CI / build + unit + e2e (push) Successful in 6m8s
2026-09-19 22:52:02 +02:00
kamilkrzywda 570ad52d47 feat: v0.20.0 part 2 — ISS-028 on-board rows two-line layout + airport name ellipsis
CI / build + unit + e2e (pull_request) Successful in 7m1s
2026-09-19 20:41:33 +00:00
kamilkrzywda 59974d1b79 feat: v0.20.0 — ISS-021 move On board to left pane, widen left 360/280 2026-09-19 20:41:33 +00:00
kamilkrzywda af5176354a Merge pull request 'v0.19.2 — AGENTS.md: CI-only e2e workflow + connected-issues exception' (#32) from docs/agents-md-e2e-ci-only into master
CI / build + unit + e2e (push) Successful in 6m23s
2026-09-19 22:34:34 +02:00
kamilkrzywda f5468bb640 docs: v0.19.2 — AGENTS.md e2e section reflects CI-only runs; connected issues may be done together
CI / build + unit + e2e (pull_request) Successful in 7m5s
2026-09-19 20:06:40 +00:00
kamilkrzywda 0970ee4710 docs: v0.19.1 — AGENTS.md documents enforced CI gate + auto-merge
CI / build + unit + e2e (pull_request) Successful in 7m32s
CI / build + unit + e2e (push) Successful in 6m43s
2026-09-19 18:49:52 +00:00
kamilkrzywda 6e1439391a Merge pull request 'v0.19.0 — ISS-011 auto-slow to 1× when all planes have landed' (#24) from feat/auto-slow-on-idle into master
CI / build + unit + e2e (push) Successful in 5m59s
2026-09-19 20:27:00 +02:00
kamilkrzywda 86379fc9c6 feat: v0.19.0 — ISS-011 auto-slow to 1x when all planes have landed
CI / build + unit + e2e (pull_request) Successful in 6m14s
2026-09-19 18:07:42 +00:00
kamilkrzywda f0602453ea Merge pull request 'v0.18.0 — ISS-010 relocate empty planes + bigger airport markers + popup options' (#20) from feat/plane-relocation into master
CI / build + unit + e2e (push) Successful in 6m4s
2026-09-19 18:44:56 +02:00
kamilkrzywda 337eda2743 test: target the plane option specifically in relocation popup e2e
CI / build + unit + e2e (pull_request) Successful in 7m32s
2026-09-19 16:35:19 +00:00
kamilkrzywda 8852ae11f2 test: fix POPUP_AIRPORT scoping in relocation popup e2e
CI / build + unit + e2e (pull_request) Failing after 6m49s
2026-09-19 16:23:03 +00:00
kamilkrzywda 008a77e456 feat: v0.18.0 part 3 — market row to relocate a grounded plane
CI / build + unit + e2e (pull_request) Failing after 6m17s
2026-09-19 16:03:03 +00:00
kamilkrzywda 3e62d63418 feat: v0.18.0 part 2 — airport popup relocate options 2026-09-19 15:57:33 +00:00
kamilkrzywda 09c1d8bcd8 feat: v0.18.0 part 1 — ISS-010 bigger airport markers 2026-09-19 15:57:23 +00:00
kamilkrzywda 0467b54c70 Merge pull request 'v0.17.0 — ISS-008 toasts for errors/events + browser notifications' (#19) from feat/toasts-and-notifications into master
CI / build + unit + e2e (push) Successful in 6m31s
2026-09-19 17:33:14 +02:00
kamilkrzywda 51d4f21d6b fix: v0.17.0 — sim-control POST retries + permission-aware notifications e2e
CI / build + unit + e2e (pull_request) Successful in 6m52s
2026-09-19 15:23:20 +00:00
kamilkrzywda d8dc785757 feat: v0.17.0 — ISS-008 toasts for errors/events + browser notifications
CI / build + unit + e2e (pull_request) Failing after 6m59s
2026-09-19 14:52:36 +00:00
kamilkrzywda eb69359a38 Merge pull request 'v0.16.1 — ISS-007 smooth client-side sim clock with per-snapshot re-sync' (#18) from feat/smooth-sim-clock into master
CI / build + unit + e2e (push) Successful in 6m34s
2026-09-19 16:27:21 +02:00
kamilkrzywda 3acf471295 test: fix ISS-007 clock parse — replace only fixed the first space
CI / build + unit + e2e (pull_request) Successful in 6m4s
2026-09-19 14:20:34 +00:00
kamilkrzywda 7d2f8f07c8 feat: v0.16.1 — ISS-007 smooth client-side sim clock with per-snapshot re-sync
CI / build + unit + e2e (pull_request) Failing after 6m42s
2026-09-19 14:00:47 +00:00
82 changed files with 8798 additions and 1436 deletions
+2 -1
View File
@@ -1 +1,2 @@
# No environment variables required yet.
# No environment variables required yet.
GITEA_TOKEN=gitea-api-token
+4 -6
View File
@@ -1,18 +1,16 @@
# Flight Simulator — Workflow Rules
## Change process (mandatory)
1. **Small, incremental changes** — work is done one change at a time, in smaller parts; each part must be tested thoroughly with unit and e2e tests covering it before moving on.
1. **Small, incremental changes** — work is done one change at a time, in smaller parts; each part must be tested thoroughly with unit and e2e tests covering it before moving on. Connected issues/tasks that reference the same part of code MAY be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel).
2. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to master). The branch carries the commits below; open a pull request to `master`.
3. **Changelog entry** — every such change is listed in `CHANGELOG.md` with date, time and version (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
4. **Version bump** — the version is updated in `package.json`.
5. **CI must pass** — the PR's pipeline (build → unit tests → e2e, `.gitea/workflows/ci.yml`) must be green before merging. CI never deploys to production; its e2e runs against an isolated stack inside dind.
5. **CI must pass** — enforced by branch protection on `master` (required status check `CI / build + unit + e2e (pull request)`): the PR cannot be merged until CI is green. Merge via auto-merge (`POST /repos/{owner}/{repo}/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`) so it merges automatically once CI passes. CI never deploys to production; its e2e runs against an isolated stack inside dind.
6. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.13.0`). Production (`https://flight.dev.kkhost.pl`) is updated manually afterwards: `docker compose up -d --build` on the host.
## E2E tests (mandatory)
1. **Docker compose instance is the app under test** — e2e always runs against a docker-compose build, never a local `next dev` on :3000 (dev builds don't render for non-loopback clients, and a second instance splits state). Locally that means the Traefik-routed instance at `https://flight.dev.kkhost.pl`; in CI it is an isolated stack inside dind (`docker-compose.ci.yml`) — **CI never deploys to or modifies production**.
2. **Update before testing (local)** — deploy current code first: `docker compose up -d --build`, then run the suite with plain `npx playwright test`. The config's webServer block is off by default; set `E2E_DEPLOY_CMD` to have Playwright itself run a deploy command and wait for readiness.
3. **Remote browsers** — local runs drive browsers through the shared Playwright server at `192.168.1.4:3000` (Docker image tag must match this repo's @playwright/test version, pinned in devDependencies). Set `PW_REMOTE_BROWSER=0` to run local chromium instead — CI does, because the remote server cannot reach a stack inside dind.
4. **CI env vars** (`playwright.config.ts`, all optional; defaults preserve the local flow) — `E2E_BASE_URL` (app under test), `E2E_DEPLOY_CMD` (webServer deploy command), `PW_REMOTE_BROWSER=0` (local browsers); `e2e/state.spec.ts` reads `E2E_COMPOSE_ARGS` for its mid-run container restart.
1. **CI-only** — e2e runs ONLY in CI, against an isolated docker stack inside dind built from `docker-compose.ci.yml` (project name `flight-ci`, app container on a static IP; see `.gitea/workflows/ci.yml`). There are NO local e2e runs at all: locally you verify with `tsc --noEmit` + `npx vitest run` + `npx playwright test --list` (typecheck, unit suite, specs compile and list), and the full e2e suite is verified by CI on every PR. **CI never deploys to or modifies production.**
2. **CI env vars** (`playwright.config.ts`, all optional) — `E2E_BASE_URL` (app under test; CI sets it to the in-network app host, so the config's default of the live Traefik instance at `https://flight.dev.kkhost.pl` is never used), `E2E_DEPLOY_CMD` (runs as Playwright webServer: deploys the stack and waits for readiness before tests start; CI uses build + `up -d` + `logs -f`), `PW_REMOTE_BROWSER=0` (CI runs local chromium because the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind — when unset, the config defaults to that remote server and to the live instance as baseURL), and `E2E_COMPOSE_ARGS`, read by `e2e/state.spec.ts` for its mid-run `docker compose restart flight` (CI points it at `-f docker-compose.ci.yml -p flight-ci`).
## API
1. **Live base URL** — `https://flight.dev.kkhost.pl` (the docker-compose build behind Traefik).
+250
View File
@@ -1,5 +1,255 @@
# Changelog
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
## v0.47.0 — 2026-09-21 23:01
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
## v0.44.0 — 2026-09-21 17:10
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
- **Generated jobs are distance-capped.** New `MAX_JOB_LEG_KM = 9000` in `lib/game/seed.ts`: every spawned leg is flyable by at least one catalog plane even in worst-case headwind (b777 full-tank range ~13045 km nominal, ~10871 km with the +20% wind factor — 9000 leaves margin). `generatePackage` rejection-samples unforced origin/dest pairs until they fit (bounded at 25 attempts, then a uniform within-cap fallback); forced endpoints consume no extra draws and stay uncapped (the UNNT→LPPD 409 backstop relies on that), so under-cap RNG streams are byte-identical to before. The engine's coverage spawn caps its random dest through the shared `pickDestWithinCap` helper — one draw in the same stream position as before
- **UI/spec/docs.** Map header label → "N world airports" (e2e regex updated), OpenAPI `/api/airports` summary/description reworded to the curated world dataset, PLAN.md dataset mentions updated; initial camera stays EU-tuned (out of scope)
- **E2E marker isolation re-verified.** LFRS (Nantes) keeps ≥39 px clearance from its nearest markers (LFRB Brest / LFBD Bordeaux) at the initial zoom [10,52] z4 with the world dataset — no test changes needed beyond the label regex
## v0.43.0 — 2026-09-21 15:57
- **Wear rate reduced 10× per ISS-085** — `WEAR_PER_KM` in `lib/game/catalog.ts` drops from 0.005 to 0.0005, so plane maintenance status degrades 1/10th as fast (~13 mid-range hops → ~130 hops to break a full-health plane)
## v0.42.0 — 2026-09-21 15:02
- **Auto-refuel on dispatch (ISS-082).** Manual refueling is gone — planes top up automatically at dispatch, so a grounded plane can always start any leg that fits a full tank when cash allows. `flyPlane` (`lib/game/actions.ts`) tops the tank to `min(maxFuel, max(fuel, 1.5 × required))` before departure — the same target/price terms as the removed forFlight mode: liters × FUEL_PRICE_PER_L minus the rep-scaled discount (v0.38.0), deducted from cash; unaffordable → 409 `insufficient funds` with nothing mutated, and a leg that doesn't fit even a full tank still 409s `insufficient fuel` (the backstop). The engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) does the same at landing: when the next queued leg exceeds the landed fuel it tops up to the identical target when cash allows and chains anyway — only an unaffordable top-up grounds the plane with the queue kept intact
- **REMOVED** `POST /api/planes/:planeId/refuel` (route + route tests), the `refuelPlane()` action + `RefuelOptions` (`lib/game/actions.ts`), the left-pane "Refuel to full" section and per-leg Refuel button with their dead state/derived values (`components/LeftPane.tsx`), and the OpenAPI `/api/planes/{planeId}/refuel` path + `RefuelRequest` schema — endpoint count 14 → 13, stale refuel mentions reworded in info/state/queue/repair descriptions, and fly's description + 409 now document the auto-refuel
- **Test hook.** New POST `/api/test/set-fuel` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, fuel }` forces a plane's fuel — finite number in [0, maxFuel] (400 outside / unknown field), 404 unknown plane; sibling of set-health
- **Tests.** Unit: `refuelPlane` suite deleted; new `flyPlane` auto-refuel tests (exact top-up target + rep-discounted cost at rep 30, free no-op on a full tank, cash 0 → 409 insufficient funds with fuel unchanged, UNNT→LPPD backstop still 409s); the broken-burn test reworked to pin that ×1.5 burn raises the top-up target; `engine.test.ts`'s chain test flipped — a short tank now chains via an affordable top-up (post-top-up fuel + exact cost from the real helpers) plus a new cash-0 variant grounding with the queue intact. E2E: every `/refuel` setup/retry site (~25 across 9 specs) replaced by the set-fuel hook, heal helpers drop their refuel-and-retry guard (dispatch auto-refuels), right-pane's per-leg Refuel test and two-planes' "refuel section lists both planes" deleted in favor of one new end-to-end dispatch auto-top-up test (set-fuel drops fuel to 500 L → fly → exact cash delta from snapshot + real helpers, landed fuel = weighted burn from the topped start value), panes.spec.ts now asserts Refuel-UI absence in both panes, api.spec.ts pins thirteen endpoints
## v0.41.0 — 2026-09-21 13:26
- **Queue UI fixes (ISS-077).** Three queue-related map/UI fixes, no server logic changed:
- **Would-409 controls are disabled.** The on-board "Fly to …" button (`components/LeftPane.tsx`) is additionally disabled while the plane is flying and its destination is already active or queued (title "Already in flight queue"); the Relocate section disables its GO button for the same reason AND renders the selected plane's active/queued airports as native disabled `<option>`s. Grounded planes are untouched — their clicks go to `/fly`, which has no such conflict
- **Hover trace starts at the landing airport.** Hovering an on-board package while its carrier is FLYING now traces from the carrier's LANDING airport (`destIcao`), not its live mid-flight position (`components/Map.tsx`) — the hover previews what happens AFTER this leg lands; the solid active-leg + dotted queue layers already show where it is. Grounded / no-active-leg carriers keep today's behavior exactly (live position = the plane's airport). `hoverTraceGeoJson` returns an empty collection when start == dest, so a package destined for the active leg's destination draws nothing there — the solid layer covers it
- **Grounded queues draw dotted lines.** `queuedRoutesGeoJson` (`lib/mapData.ts`) now also draws GROUNDED ('available') planes with a non-empty queue, chaining from their current airport (`locationIcao → queue[0] → …`); flying planes keep the `[destIcao, ...queue]` chain; 'repairing' planes draw nothing. Same source/layer — no new MapLibre layer
- **Tests.** Unit: `lib/mapData.test.ts` pins the grounded chain (exact endpoints), the unknown-first-point skip, the repairing skip and the start==dest empty trace; all flying-case pins stay green. E2E: `e2e/hover.spec.ts`'s mid-flight test is reworked — a package destined for a QUEUED stop beyond the active leg, hover traces from the landing airport to that stop with no segment starting at the departure airport (deterministic: paused right after dispatch); new `e2e/queue.spec.ts` test pins the grounded two-leg chain HUB → DEST → QUEUE in the `queue-routes` layer
## v0.40.0 — 2026-09-21 13:06
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
## v0.39.0 — 2026-09-21 12:02
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
## v0.38.0 — 2026-09-21 10:47
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
- **Cheaper refuel** — `refuelPlane` multiplies ONLY the price term: `liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2))`; burn/target math (wind factor, ×1.5 forFlight margin) untouched
- **Better jobs at higher reputation** — `generatePackage` (`lib/game/seed.ts`) takes an optional `reputation` input (defaults to the starting value) and multiplies the final reward by `1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0)` at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
- **Spawn sites carry live reputation.** `spawnOpenPackages` + `spawnCoveragePackages` (`lib/sim/engine.ts`) read the account row once per spawn batch and thread its reputation into `generatePackage`; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). `buildSeed` keeps defaulting — no rep at seed time
- **UI shows the effect.** The market buy row (`components/RightPane.tsx`) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (`market-buy-discount`, reusing `.special-badge`) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (`components/LeftPane.tsx`) previews the discounted fill-up cost with its own `refuel-discount` pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
- **OpenAPI (descriptions only).** buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; `CatalogEntry.price`, `StateSnapshot.fuelPricePerL` and `AccountState.reputation` gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
- **Tests.** New unit tests: `repRate` (zero at/below start, linear mid-range exact values, cap clamping), `buyPlane` at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), `refuelPlane` at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), `generatePackage` job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in `right-pane.spec.ts`, `two-planes.spec.ts` and `actions.spec.ts` now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)
## v0.37.0 — 2026-09-21 09:29
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
- **Rep bonus at delivery.** `deliverPackages` (`lib/sim/engine.ts`) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
- **State shape & DB.** `special: string | null` added in all four places — `SeedPackage`, Drizzle schema (`lib/db/schema.ts`), raw DDL and a guarded nullable `ALTER TABLE … ADD COLUMN special TEXT` migration in `migrateColumns()` (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and `PackageState` + its row mapping
- **UI badge.** Open-package rows (`components/RightPane.tsx`) and on-board rows (`components/LeftPane.tsx`) show a sky-blue "Priority" pill next to the route when the package has a special status (new `.special-badge` class, `package-special-{id}` testid); label via new `formatSpecialLabel` in `lib/format.ts`
- **Test hooks & spec.** POST /api/test/create-package accepts `special` (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes `special: null` explicitly. OpenAPI PackageState gains optional `special: string | null`
- **Tests.** New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces `special: 'priority'` via the hook and asserts the row shows the "Priority" badge
- **E2E self-heal hardening.** The `flyHomeViaApi` helper in `e2e/right-pane.spec.ts` now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and `e2e/slow-on-landing.spec.ts`'s equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on `e2e/two-planes.spec.ts`)
## v0.36.0 — 2026-09-21 08:45
- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note)
## v0.35.0 — 2026-09-21 02:26
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
## v0.34.0 — 2026-09-21 01:26
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
## v0.33.0 — 2026-09-21 00:45
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
## v0.32.0 — 2026-09-20 23:47
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
## v0.31.0 — 2026-09-20 21:23
- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`)
- **New test hook: POST /api/test/add-cash (ISS-055).** Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body `{ amount?: number }` (positive finite, default €1M), gated on `FLIGHT_TEST_HOOKS=1` exactly like its siblings (`app/api/test/create-package`, `/expire-package`; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen
- **New serial e2e spec `e2e/two-planes.spec.ts` (ISS-055).** Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't `plane-starter`: **load** (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), **fly** (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves `paused === false && timeScale === 1`, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes `lastSelectedTimeScale` = 1000 in the /fly response), **refuel** (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), **relocate** (moves one plane, starter untouched), **queue** (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), **sell** (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1
- Verified: `tsc --noEmit` clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); `npx playwright test --list` = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack
## v0.30.0 — 2026-09-20 19:47
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
## v0.29.1 — 2026-09-20 18:22
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
## v0.29.0 — 2026-09-20 14:27
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
## v0.28.0 — 2026-09-20 13:55
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
- **Derived max range (ISS-047).** New pure `maxRangeKm(burnPerKm, emptyWeightKg, maxFuel)` in `lib/sim/movement.ts` — closed form of the weighted burn model solving `requiredFuelFor(d) = maxFuel`: `ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm)` (reuses `FUEL_DENSITY_KG_PER_L`, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion
- **Sell-plane cargo guard (ISS-047).** `sellPlane` (`lib/game/actions.ts`) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any `loaded`/`expired` packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as `loadPackage`; the last-plane and flying-plane guards are unchanged
- Tests: unit — new `lib/sim/movement.test.ts` block for `maxRangeKm` (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new `lib/game/catalog.test.ts` sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), `lib/game/actions.test.ts` sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — `e2e/right-pane.spec.ts`: the market test clicks `tab-planes-market` first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of `maxRangeKm`, and the Boeing 777 card with its €9M price; `e2e/toasts.spec.ts` clicks the tab before its market-buy flow; `e2e/panes.spec.ts` asserts both tab buttons render with Jobs default-active. No new endpoints — `lib/openapi.test.ts` untouched
- Verified: `tsc --noEmit` clean; unit suite green locally (318, was 311); `npx playwright test --list` = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack
## v0.27.1 — 2026-09-20 13:16
- Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046).
## v0.27.0 — 2026-09-20 12:27
- **Packages now have real weight (ISS-045).** New `weight_kg REAL NOT NULL DEFAULT 2000` column on `packages` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`; pre-existing rows backfill to the mid-range default). `generatePackage` draws it AFTER cargoCount: `UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng())`, rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table `UNIT_WEIGHT_KG` in `lib/game/seed.ts` (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
- **Plane capacity is now max payload kg, not 6 slots (ISS-045).** `capacity` REMOVED from `PlaneType`/`CatalogEntry`/snapshot catalog; replaced by `maxPayloadKg` — ATR 72-600 = 4800 kg (`lib/game/catalog.ts`). Server load validation (`loadPackage`, `lib/game/actions.ts`) now sums `weightKg` over the plane's loaded+expired packages and 409s with `no payload room: the ATR 72-600 carries X/Y kg` when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: `slotsUsedFor` → `cargoWeightFor(state, planeId)` (summed kg) in `components/RightPane.tsx`, and `loadEligiblePlanes` compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
- **Payout now includes a per-kg part (ISS-045).** New `rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg)` with `REWARD_PER_KG = 0.5` (`lib/game/seed.ts`) — replaces the distance-only `rewardForDistanceKm`, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
- **Loadout display shows tonnage (ISS-045).** Left-pane fleet rows now render `X.Xt / Y.Yt` (used/max payload, one decimal) instead of `n/6 pkgs`; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains `PackageState.weightKg` and swaps `CatalogEntry.capacity` → `maxPayloadKg`, so the e2e path list stays at twelve
- **Test hook gains a forced weight (ISS-045).** `generatePackage` accepts an optional `weightKg` that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and `POST /api/test/create-package` passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
- Tests: unit — `lib/game/seed.test.ts` (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), `lib/game/actions.test.ts` (loadPackage fill loop now inserts weights summing past maxPayloadKg → `/no payload room/`; expired-still-occupies reworked weight-based), `components/right-pane.test.ts` (`cargoWeightFor` sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), `lib/openapi.test.ts` (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in `deriveEvents`/`mapData` tests and the e2e interfaces. E2E — `e2e/right-pane.spec.ts` asserts `maxPayloadKg === 4800`; `e2e/panes.spec.ts` loadout regex → `/^\d+\.\dt \/ \d+\.\dt$/`; snapshot key lists in `app/api/state/route.test.ts` + `e2e/state.spec.ts` gain `weightKg`; the two multi-load tests in `e2e/actions.spec.ts` pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
- Verified: `tsc --noEmit` clean; unit suite green locally (311, was 308); `npx playwright test --list` = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack
## v0.26.0 — 2026-09-20 11:28
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport `<select>` (`queue-add-dest`) + "Add to queue" button (`queue-add` → `POST …/queue {destIcao}`) reusing the pane's already-fetched `airportOptions` (no second `/api/airports` fetch); buttons disable on `actionsBusy`, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`) unchanged
- Tests: unit — `lib/mapData.test.ts` gains a `queuedRoutesGeoJson` block mirroring the `routesGeoJson` tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length `n_legs × SEGMENTS + 1`; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new `e2e/queue.spec.ts` (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via `queue-add-dest`/`queue-add`, assert `queue-leg-0` appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered `queue-routes` segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries `planeId: plane-starter`; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (`status 'flying'` to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
- Verified: `tsc --noEmit` clean; unit suite green locally (308, was 303); `npx playwright test --list` = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack
## v0.25.0 — 2026-09-20 10:36
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
- **New endpoints (ISS-043).** `POST /api/planes/{planeId}/queue` body `{destIcao}` appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); `POST /api/planes/{planeId}/queue/cancel` body `{index}` removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through `performAction()` like every other action and return the full state snapshot. New actions `queueLeg`/`cancelQueuedLeg` in `lib/game/actions.ts`; routes under `app/api/planes/[planeId]/queue/`
- **Snapshot + spec (ISS-043).** `PlaneState.queue: string[]` added to the `/api/state` snapshot (`lib/game/state.ts`, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — `PlaneState.queue`, `QueueLegRequest`, `CancelQueuedLegRequest`, both new paths (200→snapshot, 400, 404)
- Tests: unit — `lib/sim/engine.test.ts`: auto-chain block in `deliverPackages` (lands with queue + enough fuel → `'flying'` to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. `lib/game/actions.test.ts`: `queueLeg` (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and `cancelQueuedLeg` (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). `lib/openapi.test.ts`: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: `queue: []` added to the `PlaneState`/seed fixtures in `deriveEvents`, `right-pane`, `mapData`, `seed` tests
- Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: `tsc --noEmit` clean; unit suite green locally; `npx playwright test --list` unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.24.0 — 2026-09-20 09:48
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
- Tests: unit — `lib/sim/engine.test.ts`: the auto-slow describe block rewritten for pause semantics (last plane lands → `paused === true` AND stored `timeScale` UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). `lib/game/actions.test.ts`: new test — dispatching on a paused sim clears `paused` and leaves `timeScale` untouched. E2E — `e2e/auto-slow.spec.ts` renamed to `e2e/pause-on-landing.spec.ts` and reworked: after the starter lands, asserts `simState.paused === true` AND `timeScale` still 1000 (NOT reset), then a subsequent `/fly` dispatch clears `paused` on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec `setScale` helper (`actions`, `hover`, `map-interactions`, `relocation`, `right-pane`) now POSTs `{ timeScale, paused: false }` — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (`sim.spec.ts` / `panes.spec.ts` freeze tests, marker-click freezes) are untouched
- Verified: `tsc --noEmit` clean; unit suite green locally (291); `npx playwright test --list` = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.23.2 — 2026-09-20 09:15
- **On-board package hover previews the route from the carrier's current position (ISS-040).** Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder `hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao)` in `lib/mapData.ts` (sibling of `hoverRouteGeoJson`, same shape/conventions; empty collection when the destination is missing); `components/Map.tsx`'s `applyHoverRef` looks up the carrier via the hover's `planeId` and uses it, falling back to the ICAO trace when no carrier is in state. The `[state]` effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles
- Tests: unit — `lib/mapData.test.ts` gains a `hoverTraceGeoJson` block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — `e2e/hover.spec.ts`: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home
## v0.23.1 — 2026-09-20 08:42
- **Toasts repositioned to the map's right edge (ISS-039).** The `.toasts` stack was `position: fixed` at the viewport bottom-right, overlapping the 280px right pane; its `right` changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
- Tests: e2e wording only — `toasts.spec.ts` header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.23.0 — 2026-09-20 08:25
- **Refuel-to-full row moves to the left pane (ISS-035).** Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The `.market-row` JSX (`market-refuel-none` "All tanks full." span / `market-refuel-plane` select of grounded planes labeled `{planeName} — {fuel}/{maxFuel} L`, `market-refuel-cost` preview, `market-refuel` button → `POST /api/planes/{id}/refuel`) moved verbatim from the Market section in `components/RightPane.tsx` into a new "Refuel" `<section data-testid="refuel-section">` in `components/LeftPane.tsx`, inserted between Relocate and Time. Its state (`refuelPlaneId`) + derived values (`refuelable`, effective-selection `refuelPlane`, `refuelCost`) moved with it, reusing LeftPane's existing `grounded` list; busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (`refuel-for-{pkg.id}`, mode 'forFlight') is a different feature and stays put; exported helpers `slotsUsedFor` / `loadEligiblePlanes` untouched
- Tests: e2e — `right-pane.spec.ts`: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside `[data-testid="left-pane"]` (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. `panes.spec.ts`: first test gains structural assertions — Refuel heading + `market-refuel` visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.22.2 — 2026-09-20 07:32
- Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes
## v0.22.1 — 2026-09-20 06:57
- Hygiene: gitignore the local-only ORCHESTRATOR.md successor-agent manual (it references host-specific setup and must never be version-controlled); no code changes
## v0.22.0 — 2026-09-19 21:42
- **Hover route trace on job-list rows (ISS-022).** Hovering an open package in the right pane now draws the ENTIRE great-circle trace between origin and destination, not just the two endpoint rings. Zero pane edits — the rows already sent `{kind:'package', originIcao, destIcao}`; `components/Map.tsx` now derives from/to for any hover target carrying two ICAOs and pushes a new pure builder's output (`hoverRouteGeoJson(fromIcao, toIcao, airportsByIcao)` in `lib/mapData.ts`: one LineString via the existing `greatCircleArc` slerp, empty FeatureCollection when either endpoint is missing from the airport index or the `''` no-hover sentinel) into a NEW dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer (paint: `#facc15` matching the airports-highlight ring, width 4, `line-dasharray [2,2]`). The shared `routes` source can't carry this — it only holds FLYING legs, so an open package's or a grounded relocation's planned route was never in it. The layer is added in `ensureLayers` after the highlight layers so style switches keep it; `applyHoverRef` re-pushes data on every hover
- **Same trace on On-board cargo rows (ISS-025).** Each `onboard-package-{id}` row in the left pane's On board section now reports `{kind:'package', originIcao, destIcao, planeId: plane.id}` on mouseenter — same full trace as job-list rows PLUS the carrying plane's halo stays lit via the new optional `planeId` field (Map uses `h.planeId ?? ''` for the planes/routes-highlight filters). Mouseleave restores the block's plain `{kind:'plane', id}` highlight; leaving the whole block still ends at null because React fires the child's mouseleave before the parent's
- **Relocate panel hover previews the planned flight (ISS-027).** The left pane's Relocate section gains `data-testid="relocate-section"` + enter/leave handlers: with a grounded plane AND destination selected it reports `{kind:'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao}` (else null), and Map draws the planned trace AND rings ONLY the target airport in airports-highlight (origin stays unmarked — the destination is what the issue asks to see). `HoverTarget` (`components/hoverTypes.ts`) extends to `{kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}`. Existing semantics unchanged: plane hovers filter the three highlight layers exactly as before, package hovers keep BOTH endpoint rings (now plus the trace)
- **Target airport highlighted in a distinct color on job-list / relocation hover (ISS-022).** The destination of any two-endpoint hover now gets its own ring in a NEW top-most `airports-target-highlight` circle layer (`components/Map.tsx`, same `airports` source and r=9 transparent-fill geometry as airports-highlight, but green `#4ade80` stroke width 3 vs the yellow `#facc15` endpoint rings) — added last in `ensureLayers` so it also sits above the hover-route line at its endpoint; `applyHoverRef` filters it to the destination icao for BOTH `'package'` and `'route'` hovers (empty-string sentinel otherwise). airports-highlight itself is untouched, so the existing both-endpoints / target-only assertions stay valid
- Tests: unit — new `describe('hoverRouteGeoJson …')` in `lib/mapData.test.ts`: no-hover sentinel → empty; two known airports → one arc with exact dataset endpoints; reversed-input symmetry; missing from/to ICAO → empty (baseline 283 → 288). e2e (`e2e/hover.spec.ts`) — open-package test extended with hover-route endpoint assertions (line geometry first/last coords compared against `/api/airports` lookups) + trace-clear check; new `hovering an on-board cargo row draws its trace and keeps the plane halo` (load via API, hover the row, assert trace endpoints + planes-highlight for that plane, abandon the package afterwards to leave a clean instance); new `hovering the Relocate section previews the planned route and target airport` (selects starter + LEMD via the existing testids, hovers `[data-testid="relocate-section"]`, asserts trace endpoints + target-only ring). New helpers: `airportLonLat(page, icao)` and `expectHoverRoute(page, fromIcao, toIcao)`; all new/extended assertions call `fitToAirports` first since `queryRenderedFeatures` is viewport-scoped
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests (was 39, +2 new in hover.spec.ts); e2e not run locally by design — CI verifies against an isolated stack
- **Fix: hover-route e2e timeouts (CI run #69).** All three trace tests failed at `expectHoverRoute` with the 15 s "waiting for the hover-route line EDDF → LEMD" timeout although the dashed trace WAS drawn correctly — the helper assumed `queryRenderedFeatures(undefined, {layers:['hover-route']})` returns exactly one feature whose endpoints are bit-identical to the `/api/airports` dataset coords. MapLibre GL JS stores GeoJSON geometry in per-tile INTEGER grids (`Math.round(extent·(x·2^z − tx))`, maplibre-gl 6.10) and clips long lines across tiles, so a 1420 km leg comes back as one feature PER intersecting tile (empirically 4 at z=4 / 5 at z=5 for EDDF→LEMD, verified against the installed build's GeoJSONVT) with endpoints quantized by up to ~half a grid step (~0.001° observed at z≥4, ~0.02° worst case at z=0): both the `feats.length !== 1` guard and the strict `===` coordinate comparisons could never be true. The helper now accepts any number of segments and matches endpoints within 0.05° (~5 km — above worst-case quantization at any zoom, below the distance between any two EU airports): some segment must start at origin and some end at destination; all other assertions unchanged. App side verified correct (no race: `ensureLayers` awaits the airport fetch before creating sources/layers and re-applies hover afterwards; layer/source ids match) — no app changes
- **Fix: hover-route e2e — evaluate maplibre geometry in-page before Playwright serialization (CI runs #69–#71).** All three trace tests failed for a serialization reason, not a data one: `renderedFeatures` returned live `GeoJSONFeature` instances from `queryRenderedFeatures`, whose `geometry` is a lazy PROTOTYPE getter — and Playwright's evaluate serialization copies only own enumerable properties (verified empirically), so every feature arrived in test context WITHOUT geometry. Run #69 timed out on the strict length guard before touching it, run #70 threw `TypeError: Cannot read properties of undefined (reading 'coordinates')`, run #71's defensive filter found nothing and timed out. The helper now maps each feature to a plain object inside page.evaluate so the getter is evaluated in-page; the endpoint-matching predicate keeps its degenerate-segment filter as belt-and-braces — no app changes
## v0.21.0 — 2026-09-19 21:01
- **Relocate panel moves to the left pane (ISS-023).** Flying an empty/grounded plane to another airport is "anything with our plane", so it belongs in the left pane, not as a market row on the right. The `.market-row.market-row-relocate` JSX (`relocate-plane` select of grounded planes labeled `{planeName} — {icao}`, `relocate-dest` name-sorted select, `relocate-go` button → `POST /api/planes/{id}/fly {destIcao}`) moved verbatim from `components/RightPane.tsx` into a new "Relocate" `<section>` in `components/LeftPane.tsx`, inserted between On board and Time. Its state (`relocatePlaneId` / `relocateDestIcao`) and the one-time `/api/airports` fetch (promise-ref pattern, GeoJSON → `{name, icao}[]` sorted by name) moved with it — the relocate-dest select was that fetch's only consumer in RightPane, so nothing else there changed. Busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`); a grounded-plane filter was added next to the existing `onboard` compute; plane labels reuse the expression already inlined in the Fleet rows. Testids kept byte-identical (`relocate-plane` / `relocate-dest` / `relocate-go`) and the row keeps its `.market-row market-row-relocate` classes (global CSS, works in the 360 px left pane); stale "280 px right pane" comment updated in `app/globals.css`. The map-popup relocation controls (`airport-popup-*`, Map.tsx) are a separate surface fed by the snapshot only — untouched
- Tests: e2e — only `relocation.spec.ts` touches these testids and all locators are global, so no locator changes; the first test's title (which mentioned "right pane") and the file header comments now say left-pane Relocate section. No unit-test changes (baseline 283)
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
## v0.20.0 — 2026-09-19 19:21
- **On board moved to the left pane (ISS-021).** The "On board" section (per-plane blocks with loaded/expired cargo rows, Refuel-for-leg / Fly-to / Abandon + penalty preview) now renders in `components/LeftPane.tsx` between Fleet and Time — wallet stays on top. The JSX moved verbatim (same testids: `onboard-{planeId}`, `onboard-package-{pkg.id}`, `refuel-for-*`, `fly-to-*`, `abandon-*` + penalty preview), along with the `onboard` computation (`planes.filter(flying || slotsUsedFor > 0)`) and per-plane cargo filter. All onboard e2e locators are global testids, so existing specs work unchanged
- **Left pane widened: grid swapped to 360/1fr/280.** `.app-shell` in `app/globals.css` goes from `280px minmax(0, 1fr) 360px` to `360px minmax(0, 1fr) 280px` — total fixed width stays 640 px, so the map column is pixel-identical (map.spec.ts clicks via dynamic `map.project()`). Stale comments updated (on-board blocks now in the left pane; Relocate row comment reflects the 280 px right pane)
- **Shared action runner hook.** RightPane's local `runAction` (busy gate, inline pane error + shell toast on failure, full post-action snapshot pushed via `applySnapshot`) extracted to new `lib/useGameAction.ts`: `useGameAction(state, applySnapshot, onError) → { runAction, busy, actionError }`, exact semantics kept incl. the non-2xx throw/catch and inline-error auto-clear on the next snapshot. Used in BOTH panes; LeftPane receives `applySnapshot` from the shell (`app/page.tsx`). RightPane keeps `[data-testid="right-error"]`; LeftPane gains a matching `[data-testid="left-action-error"]`. Pure helpers `slotsUsedFor` / `loadEligiblePlanes` stay exported from RightPane (unit tests import them there; LeftPane imports `slotsUsedFor` from it); the Market Relocate row + `/api/airports` fetch stay in RightPane
- Tests: e2e — `panes.spec.ts` first test now also asserts the "On board" heading is visible inside `[data-testid="left-pane"]` and absent from the right pane; stale header comments updated in `actions.spec.ts` / `right-pane.spec.ts`. No unit-test changes (baseline 283)
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
- **On-board cargo rows are two lines (ISS-028).** Each `onboard-package-*` row now stacks a full-width "Fly to <airport>" button on its own line above Refuel + Abandon side by side: `components/LeftPane.tsx` moves the fly-to button out of `.contract-main` into the row root and wraps refuel-for + abandon in a new `.onboard-actions` div — all testids, label text, penalty preview and disabled states unchanged. Long destination names in the fly-to label truncate with an ellipsis instead of overflowing: the label's inner span gets `min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap`, full name preserved via a `title` attribute (CSS in `app/globals.css`: `.onboard-block .contract-row` becomes flex-column, new `.onboard-fly-btn` / `.onboard-fly-label` / `.onboard-actions`). No e2e changes — specs locate everything by testid and assert no layout or button order; no version bump (part 2 of v0.20.0)
## v0.19.2 — 2026-09-19 20:06
- **AGENTS.md "Change process" item 1** — connected issues/tasks that reference the same part of code may now be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel)
- **AGENTS.md "E2E tests" section rewritten to match current reality** — e2e runs ONLY in CI, against an isolated docker stack inside dind (`docker-compose.ci.yml`); there are NO local e2e runs at all (local verification = `tsc --noEmit` + `npx vitest run` + `npx playwright test --list`; the full suite is verified by CI on every PR). Dropped the stale local-run instructions (deploying to the Traefik-routed instance before testing, driving browsers through the remote Playwright server at 192.168.1.4:3000); kept and reworded the CI env vars that actually exist in `playwright.config.ts` (`E2E_BASE_URL`, `E2E_DEPLOY_CMD`, `PW_REMOTE_BROWSER=0`, `E2E_COMPOSE_ARGS`)
## v0.19.1 — 2026-09-19 18:48
- AGENTS.md: document that CI-green is enforced on master by branch protection (required status check) and that merges use auto-merge (`merge_when_checks_succeed`) so PRs merge automatically once CI passes
## v0.19.0 — 2026-09-19 18:05
- **Auto-slow to 1× when all planes have landed (ISS-011).** When the last plane in flight lands, the sim slows down on its own so an idle fleet doesn't sit at 1000×. Server-side and authoritative: `tickOnce` (`lib/sim/engine.ts`) now remembers how many planes `deliverPackages` finalized during a tick, and when ≥1 plane landed this tick AND no plane remains with status `'flying'` AND the sim is not paused, it persists `timeScale = 1` on the same `sim_state` write as the clock — so the UI slider reflects it on the very next snapshot (no API or OpenAPI change: timeScale is still set via the existing `POST /api/sim`, and `clampTimeScale` semantics are untouched since 1 is a valid ladder value)
- **Arrival-triggered, not idle-enforced.** The trigger fires only on the tick that processes a landing — a grounded fleet at high scale with no flight ever happening is left alone (this keeps the existing slider e2e in `panes.spec.ts` green: it sets 1000× and expects the server to confirm it while nothing is flying). Paused state is untouched: while paused no landings are processed, so a paused sim never auto-slows
- **No auto-restore by design.** The previous scale is not remembered — once the game slows to 1× after the last landing, the player speeds up manually again (slider or `POST /api/sim`). Deliberately minimal scope: no "resume my old speed" behavior
- Tests: unit — new `describe('auto-slow …')` block in `lib/sim/engine.test.ts`: last plane lands → stored timeScale becomes 1; a landing while another plane is still flying → no change; already at 1× → stays 1; paused sim with all planes landed → no change. e2e — new `e2e/auto-slow.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like relocation.spec.ts): sets 1000× via the API, dispatches plane-starter on the shortest leg from its hub (EDDF → nearest airport), waits for the landing and asserts the server's `timeScale` is exactly 1 in that snapshot, then proves manual override still works (`POST /api/sim {timeScale: 1000}` confirmed by response + fresh snapshot)
- **e2e suite adapted to the new behavior.** Every existing spec that flies out-and-back relied on 1000× persisting across a landing — with ISS-011 it no longer does, so each return leg now re-sets SCALE explicitly: `flyHomeViaApi` in actions/relocation/right-pane/hover (re-set before the repositioning flight after waiting for an in-flight leg to land) and the restore legs inside the test bodies of actions.spec.ts (core loop, expired packages, validation paths ×2), right-pane.spec.ts (fly-to-destination, refuel-for-leg) and hover.spec.ts (flying-plane route highlight). map-interactions.spec.ts needed no change — its `loadAndFly` helper already re-sets SCALE before every landing wait
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
## v0.18.0 — 2026-09-19 16:02
- **Relocate a grounded (empty) plane to any airport (ISS-010).** `flyPlane` has allowed pure repositioning since v0.12.0, but the UI only exposed flying for planes that carried cargo — an empty grounded plane had no fly control anywhere. Two new surfaces now drive the EXISTING `POST /api/planes/{id}/fly {destIcao}` endpoint (no API or OpenAPI change):
- **Right-pane market row** (`components/RightPane.tsx`): a fourth `.market-row` with `[data-testid="relocate-plane"]` (grounded planes, "ATR 72-600 — EDDF" labels), `[data-testid="relocate-dest"]` (all airports from a one-time `GET /api/airports` fetch per page life — same promise-ref pattern as Map.tsx — sorted by name for stable e2e selection) and the `[data-testid="relocate-go"]` "Relocate" button, disabled while busy or until both selects have a value. Same effective-selection + `runAction` pattern as the buy/sell/refuel rows (first grounded plane / first airport preselected); the row wraps (`market-row-relocate`) since two selects don't fit one 360 px line
- **Airport popup Relocate row** (`components/Map.tsx`): after the name/ICAO/local-time/weather lines, a `.popup-relocate` block with `[data-testid="airport-popup-plane"]` (grounded planes from the latest polled snapshot at click time) and `[data-testid="airport-popup-relocate"]` "Relocate here" — disabled until a plane is picked; on click it POSTs fly to the clicked airport, pushes the response snapshot through the shell's `applySnapshot` (new Map prop, same callback RightPane receives), and reports success/failure in `[data-testid="airport-popup-relocate-status"]`. With no grounded planes the row shows `[data-testid="airport-popup-no-planes"]` instead. Popups are plain HTML, not React — controls wired via `popup.getElement().querySelector(...)` after `.addTo(map)`
- **Bigger airport markers (ISS-010).** The r=3 dots were hard to click: `airports-circles` paint in `components/Map.tsx` goes from radius 3 / stroke 1 to radius 5 / stroke 1.5, and the hover highlight ring follows (r=8 → r=9). Paint-only — no data or interaction changes
- No server or OpenAPI changes — both surfaces reuse the existing fly endpoint (validations unchanged: 404 unknown plane / 409 flying or insufficient fuel / 400 unknown ICAO, strict unknown-field rejection kept)
- Tests: e2e — new `e2e/relocation.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts `/api/state` shows `flying` with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing
- Verified: `tsc --noEmit` clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack
- **Fix (CI run #50): POPUP_AIRPORT scoping in the popup relocation e2e.** The LFRS marker click passed `POPUP_AIRPORT` into a `page.evaluate(() => …)` callback that runs in the BROWSER, where Node-scope constants don't exist — deterministic `ReferenceError: POPUP_AIRPORT is not defined` (relocation.spec.ts popup test, both attempts of run #50). The constant is now passed as an evaluate argument (`page.evaluate(({lon, lat}) => …, POPUP_AIRPORT)`); the other evaluate in the file only touches browser globals and needed no change. Test-only — no app code touched
- **Fix (CI run #51): strict-mode locator in the popup relocation e2e.** `expect(planeSel.locator('option')).toContainText(…)` resolved to BOTH options (placeholder + plane) → strict mode violation (relocation.spec.ts popup test, both attempts of run #51; the app rendered correctly). The assertion now targets `option[value="plane-starter"]` specifically with `toHaveText`. Test-only — no app code touched
## v0.17.0 — 2026-09-19 14:49
- **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `<ToastStack>` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css`
- **Event messages via snapshot diffing.** No server event feed exists, so new pure `lib/deriveEvents.ts` — `diffSnapshots(prev: StateSnapshot | null, next) → {kind:'info', message}[]` — derives human-readable events from the fields already in `/api/state`: plane landing (`flying→available`, "ATR 72-600 landed at Frankfurt"), package delivery (`loaded→delivered`, "Package delivered: +€X" with the reward), expiry (`open|loaded→expired`, "Package expired: A → B"), new packages (id only in next, "New package available: A → B") and planes bought/sold (plane id set changed, "Bought/Sold {model}"). The shell keeps a `prevRef` and diffs on every snapshot change, pushing each event as an info toast; the first snapshot after load emits nothing (no baseline — no page-load spam)
- **Browser notifications when the window is not focused.** Each derived event also fires `new Notification('Flight Ops', {body})` when `document.hidden && 'Notification' in window && Notification.permission === 'granted'`. The left-pane Time section gains an explicit opt-in button (`[data-testid="enable-notifications"]`) calling `Notification.requestPermission()`; its label reflects the result ("Enable notifications" → "Notifications enabled"/"Notifications blocked"), and it renders disabled with a tooltip title when the API is absent. SSR-safe: permission state starts null and is read in an effect after hydration
- No server or OpenAPI changes — diffing uses only existing snapshot fields (verified: planes by id/status/locationName, packages by id/status/reward/origin+dest names)
- Tests: unit — new `lib/deriveEvents.test.ts` suite pins every event format (landing, delivery with reward, expiry from open and loaded, new-package appearance, bought/sold, identical snapshots → [], first snapshot → [], non-events like takeoff/load ignored); `lib/useToasts.test.ts` covers the queue cap. e2e — new `e2e/toasts.spec.ts`: deterministic insufficient-funds path (select the most expensive model in `[data-testid="market-buy-model"]`, click Buy, expect a visible error toast matching /insufficient funds/ AND the inline `right-error` still showing; skips only if cash already covers the plane on a long-lived local instance) and the notifications opt-in (unsupported → disabled + title; otherwise fresh context shows "Enable notifications" and after clicking the label reflects the result — soft, since headless CI may deny permission)
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
- **Fix (CI runs #44/#47): sim-control POSTs are retried before surfacing the error.** A dropped `POST /api/sim` on the runner's flaky uplink left the UI optimistic while the server kept the old scale — panes.spec.ts:74 timed out with timeScale stuck at 1000 (run #47). `applySimControls` now retries each commit up to 3× total (~500 ms backoff) before surfacing the error toast; a newer intent arriving mid-retry supersedes the in-flight body instead of waiting out its retries (last-wins queueing kept), and the endpoint's set-semantics make re-sending an already-applied value idempotent. Pure retry core `retrySimPost` exported from LeftPane + unit-tested. Also run #47: toasts.spec.ts Part B is now permission-state-aware — headless Chromium reports `Notification.permission` as 'denied' (not 'default'), so the test branches on the actual state (denied → assert blocked label without clicking; granted → enabled label; default → prompt, click, accept either resulting label)
## v0.16.1 — 2026-09-19 14:00
- **Smooth client-side sim clock with per-snapshot re-sync (ISS-007).** The left-pane "Time" display used to jump once per 1 s poll because it rendered the snapshot's `clockMs` verbatim. It now ticks smoothly on the client between polls: new hook `lib/useSimClock.ts` keeps an anchor `{clockMs, at, timeScale, paused}` and re-derives the display clock every ~100 ms as `anchor.clockMs + (now − at) × timeScale` — exactly the server's own tick model (`tickOnce`: real-time × timeScale), so client extrapolation mirrors authoritative game time. Every new snapshot re-anchors to server truth, bounding any drift by network latency only; while paused the display is frozen at the anchor value (advance 0). The pure core `advanceSimClock(anchor, nowMs)` is exported and unit-tested in node
- **LeftPane consumes it for the `sim-clock` paragraph ONLY** (`components/LeftPane.tsx`): fed with EFFECTIVE controls `{timeScale: pendingScale ?? snapshot scale, paused}` so a slider drag or pause click takes effect on click/drag without waiting for a confirming snapshot; the `(paused)` suffix keeps coming from the effective paused flag. Right-pane deadline/penalty previews are untouched — they stay computed from the snapshot's `clockMs` (deterministic against server truth)
- No server or OpenAPI changes — display-only client work
- Tests: unit — new suite in `lib/useSimClock.test.ts` pins the extrapolation math (exact elapsed × scale advance, frozen while paused at any scale, zero-elapsed identity, clock-skew clamp never goes backwards, mirrors the tickOnce formula under the 2 s clamp). e2e (`panes.spec.ts`) — new test: at scale 1000 sample `sim-clock` three times ~1.3 s apart real time and assert the displayed clock strictly advances between CONSECUTIVE samples (with poll-only rendering a pair could straddle zero snapshots and not change), plus format check and a total-advance sanity bound
- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
## v0.16.0 — 2026-09-19 12:18
- **Weight-proportional fuel burn (ISS-005, part A).** Fuel no longer burns at a constant L/km — the instantaneous rate is proportional to total weight, `r(F) = k·(We + ρF)` L/km with We the empty weight and ρ = 0.8 kg/L (Jet-A1), so a heavier plane burns more per km and a tank gets lighter as it empties. The constant `k` is calibrated so a FULL tank burns exactly the catalog's old `burnPerKm`, keeping full-tank departure behavior identical to before; divergence only grows in flight as fuel drops off. Closed forms of `dF/dx = −k(We + ρF)` live in `lib/sim/movement.ts` (single source of truth):
- `fuelAtWeighted(startFuel, burnPerKm, emptyWeightKg, maxFuel, distanceKm, progress)` — fuel remaining after flying `progress` of the leg (replaces the old constant-rate `fuelAt`, which is removed)
+69
View File
@@ -0,0 +1,69 @@
# ORCHESTRATOR.md — Operating Manual for Successor Agents
You are taking over the autonomous development loop on **Flight-Ops** (browser game: run an airplane transport company in Europe; server-authoritative sim, MapLibre GL JS map). This document captures operational knowledge that is NOT written down in AGENTS.md. Read AGENTS.md first — it defines the mandatory change process and this file complements it.
Credentials are deliberately NOT stored in this file — see §1 for where they live. Never paste token values into any tracked file.
## 1. Project identity
- Repo: Gitea at https://git.kkhost.pl → org **OpenCode**, repo **Flight-Ops** (full name `OpenCode/Flight-Ops`). Every Gitea API/MCP call uses owner=`OpenCode`, repo=`Flight-Ops`.
- Local workdir + prod deploy dir: `/home/krzyw/projects/sandbox/flight` (git checkout; `docker compose up -d --build` deploys from here).
- Prod URL: https://flight.dev.kkhost.pl (Traefik → docker-compose stack on this host). `GET /api/version` reports the running version.
- Gitea API access for git.kkhost.pl: prefer the gitea MCP tools — they are already configured with a token. For raw REST/curl, read the current token from the local .env file at repo root (variable `GITEA_TOKEN`; .env is gitignored and must never be committed): `source .env && curl -s -H "Authorization: token $GITEA_TOKEN" https://git.kkhost.pl/api/v1/user`. (Incident note, Sept 2026: an early draft of this doc committed a live token to the repo; that commit was purged from all refs and the token has since been rotated — never repeat this.)
- Current user: kamilkrzywda (id 1).
## 2. The loop (per issue)
1. Small incremental change on a feature branch off `master`; connected issues may share one branch/PR with a single version bump.
2. Bump `package.json` version + add CHANGELOG.md entry at top: `## v<version> — YYYY-MM-DD HH:MM` then bullet points of what changed (docs-only changes get patch bumps too — precedent v0.19.1/v0.19.2).
3. Local verification ONLY (there are NO local e2e runs): `npx tsc --noEmit`, `npx vitest run` (the whole unit suite must pass — the test count grows over time, so compare against the previous green run, never a hardcoded number), `npx playwright test --list` (all specs compile and list).
4. Push → CI (~7 min) → **auto-merge on green** (PRs have auto-merge configured; no manual merge needed — verify via `GET /pulls/{n}` that state=closed and merged_at is set). Note: if a PR was created via API rather than create-on-push, double-check auto-merge actually fires.
5. Tag the version on master: `git tag v<version> origin/master && git push origin v<version>` (after `git fetch origin master`).
6. Deploy prod in a PTY (long task): `cd /home/krzyw/projects/sandbox/flight && git checkout master && git reset --hard origin/master && docker compose up -d --build`; then verify `curl https://flight.dev.kkhost.pl/api/version` returns the new version and the root page is HTTP 200.
7. Close each issue with a comment: what changed, version, PR number, merge commit SHA, deployed URL.
8. Repeat for the next open issue: `GET /issues?state=open&type=issues` — note Gitea lists pull requests in this endpoint too; filter out entries with a `pull_request` key.
## 3. Gitea API + MCP quirks (learned the hard way)
- The gitea MCP tools work for issues/PRs/files/tags, and Actions endpoints work IF you pass owner=`OpenCode`, repo=`Flight-Ops`. A wrong owner/repo makes EVERY call return `{"message":"not found","url":"https://git.kkhost.pl/api/swagger"}` — that swagger URL inside a 404 is the signature of "wrong path", not "Gitea is down".
- MCP merge endpoint 405s → don't bother; auto-merge handles merging. Manual fallback if ever needed: `POST /repos/OpenCode/Flight-Ops/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge"}`.
- Gitea REST shapes (NOT GitHub's): list runs → `{workflow_runs:[...]}`; run jobs → `/actions/runs/{id}/jobs` returns `{"jobs":[...]}`; single job log via MCP `get_job_log_preview` (param tail_lines) or `download_job_log` to a file, then grep.
- Failure anatomy in CI logs: the Playwright summary at the end lists "N failed" + test titles; each failure block has a code frame with line numbers plus an "Error Context:" path. The e2e step is the only one that can fail flakily; unit failures are real regressions.
- CI watcher pattern that works: `run_background_process` (notify=true) running a loop that polls `GET /actions/runs/{id}` every 60 s and exits when status=completed, printing status+conclusion each tick. Don't poll in the foreground; don't sleep-loop on pty_read.
## 4. CI facts (.gitea/workflows/ci.yml + docker-compose.ci.yml)
- Single job "build + unit + e2e" on runner gitea-runner (docker-in-docker). Fresh DB per run (volume removed); compose project `flight-ci`; app container on a static IP → `E2E_BASE_URL=http://172.39.0.10:3000`; `PW_REMOTE_BROWSER=0` (CI runs local chromium — the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind); `E2E_COMPOSE_ARGS="-f docker-compose.ci.yml -p flight-ci"` is read by e2e/state.spec.ts for its mid-run `docker compose restart flight`.
- Flaky uplink → retry loops in ci.yml; an occasional "flaky" test (failed once, passed on retry) does NOT fail the run.
- Branch protection requires status check `CI / build + unit + e2e (pull request)` — PRs cannot merge until green; CI never deploys to production.
## 5. E2E conventions (e2e/*.spec.ts) — read before touching tests
- Sim scale in tests: SCALE=1000; hub `HUB='EDDF'` (Frankfurt); typical destination LEMD.
- `flyHomeViaApi(page)` self-heal at test start — interrupted runs can leave planes mid-flight; landing waits up to 240 s.
- Pause-sim pattern for deterministic marker clicks: pause, act, assert, resume.
- Dev hook: `window.__flightMap` exposes the live MapLibre map instance in dev builds (also `__flightLastSnapshot`).
- `queryRenderedFeatures` is viewport-scoped → fitBounds/fitToAirports before asserting on far features; give the renderer ~300 ms after a camera jump.
- **expect.poll callbacks must be total** — a throw inside fails the test instantly instead of retrying. Guard/filter everything you dereference.
## 6. ⚠️ The big one: Playwright evaluate serialization drops prototype getters
MapLibre's `queryRenderedFeatures` returns live `GeoJSONFeature` class instances whose `geometry` is a **lazy PROTOTYPE getter** (own properties are only type/properties/id/source/state plus private fields). Playwright serializes evaluate results by copying **own enumerable properties only**, so every feature arriving in test context has NO geometry — silently.
- Verified empirically: `page.evaluate(() => { class A{constructor(){this.own=1} get geo(){return 42}} return [new A()] })` → `[{"own":1}]`.
- Symptom matrix (how it masqueraded as "the line isn't drawn" while the trace rendered fine on screen, across three consecutive failed CI runs in Sept 2026): strict length guard → timeout; direct `.geometry.coordinates` access → `TypeError: Cannot read properties of undefined (reading 'coordinates')`; defensive filter → clean 15 s timeout.
- **Rule: never return maplibre feature instances across page.evaluate — always map to plain objects in-page** (`{type, id, properties, geometry: f.geometry}` forces the getter to evaluate). Canonical pattern: `renderedFeatures()` in e2e/hover.spec.ts. Other specs that touch `.geometry` do so inside evaluate (safe) or only read array length (safe).
## 7. MapLibre rendering facts (for map assertions)
- GeoJSON geometry is stored per-tile in INTEGER grids; long lines are clipped → `queryRenderedFeatures` returns ONE FEATURE PER TILE the line crosses (the EDDF→LEMD leg ≈ 4–5 segments at z=4/5).
- Coordinates are quantized to the grid: error up to ~half a grid step (~0.001° observed at z≥4, worst ~0.02° at z=0 with extent 8192). Endpoint assertions must use an EPS sized for this (the hover tests define one in `expectHoverRoute` in e2e/hover.spec.ts — ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports) and check endpoints ACROSS ALL SEGMENTS: some segment must START within EPS of origin AND some must END within EPS of destination. Never strict equality on a single feature.
- The true line endpoints survive clipping verbatim in whichever tile contains them.
## 8. App architecture quick map
- `components/Map.tsx` — the MapLibre layer: sources/layers created in ensureLayers (dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer, plus an `airports-target-highlight` ring layer added last so it sits above the line at its endpoint — current colors/widths live in Map.tsx, don't trust this doc for exact paint values); applyHoverRef re-pushes data on every hover; `window.__flightMap` dev hook.
- `lib/mapData.ts` — pure GeoJSON builders (`hoverRouteGeoJson`, `routesGeoJson`, `greatCircleArc` slerp) + unit tests in lib/mapData.test.ts.
- Hover protocol: panes report a HoverTarget via mouseenter/leave (`components/hoverTypes.ts`): `{kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}`; the `''` sentinel means no hover.
- Server: sim ticks server-side, client polls /api/state; OpenAPI spec generated from lib/openapi.ts and served at /api/openapi — update it whenever an endpoint changes.
## 9. Finding current state (this file keeps no snapshots)
This manual is meant to stay valid long-term, so it deliberately contains NO version or issue snapshots — they rot. To orient yourself when you take over: `git log --oneline -10` + CHANGELOG.md for what has shipped; `GET /issues?state=open&type=issues` (filter out entries with a `pull_request` key) for what is left to do; PLAN.md's "Later" section for the roadmap backlog.
## 10. Standing user instruction & etiquette
- The user's standing order for this project: work through the issue list autonomously — implement → PR → CI green → merge → tag → deploy prod → close with comment → next — until told to stop.
- Small incremental changes; each part fully tested (unit + e2e) before moving on.
- Long terminal tasks → pty_* tools (deploys, builds). Background watchers → run_background_process with notify=true.
- Never push directly to master; never deploy from a feature branch; CI never touches prod — production deploys are manual only (`docker compose up -d --build` after tagging).
- **Never commit credentials.** Tokens/secrets live in the MCP server config or with the user, never in tracked files (see incident note in §1).
+3 -4
View File
@@ -5,8 +5,8 @@ No manual flying — planes auto-fly assigned routes; you manage the business fr
## Layout (single screen)
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation
- Right pane — Jobs & Market: contracts between airports (reward, deadline, cargo/passengers) with accept/dispatch actions; market actions (buy/sell planes, refuel, maintenance)
- Middle — Map: EU airports as markers, animated plane positions along routes, active contract routes highlighted; click airport → info (runways, local time, weather); click plane → status
- Right pane — Jobs & Market: contracts between airports (reward, deadline, cargo/passengers) with accept/dispatch actions; market actions (buy/sell planes, maintenance). No manual refueling — dispatch auto-tops-up the tank to 150% of the leg requirement (capped at capacity) when cash allows
- Middle — Map: world airports as markers (one per metro area), animated plane positions along routes, active contract routes highlighted; click airport → info (runways, local time, weather); click plane → status
## Core loop
Accept job → dispatch a plane → watch it fly the route on the map → earn money + reputation → buy better planes / take bigger jobs.
@@ -28,10 +28,9 @@ Accept job → dispatch a plane → watch it fly the route on the map → earn m
- Shown on map / airport info; no gameplay effects yet
## MVP slice
Starter plane + EU airports + contracts + buy/sell + map visualization + weather display.
Starter plane + world airports + contracts + buy/sell + map visualization + weather display.
## Later
- Weather effects: delays, fuel burn changes, closed airports
- More aircraft types, upgrades (fuel efficiency, cargo capacity) — money sinks
- Multi-region packs (non-EU airports)
- Multiplayer (shared world, contract races)
+10
View File
@@ -0,0 +1,10 @@
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));
}
+5 -4
View File
@@ -1,10 +1,11 @@
import { loadPackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
// package onto a GROUNDED plane at the package's origin with a free slot. 400
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409
// wrong package status, plane flying, plane not at the origin, no free slots. On
// success returns the full state snapshot (package now "loaded" on that plane).
// package onto a GROUNDED plane at the package's origin with enough payload room
// for its weight (v0.27.0). 400 missing/non-string planeId or unknown fields; 404
// unknown package/plane id; 409 wrong package status, plane flying, plane not at
// the origin, no payload room. On success returns the full state snapshot
// (package now "loaded" on that plane).
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
+6 -6
View File
@@ -63,17 +63,17 @@ describe('POST /api/planes/:planeId/fly', () => {
.where(eq(schema.planes.id, 'plane-starter'));
});
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
it('rejects a leg that does not fit even a full tank with 409 (ISS-082 backstop)', async () => {
const { db } = await getGameDb();
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
// UNNT → LPPD ≈ 7670 km needs ~10k L — beyond the ATR's 6300 L tank, so no auto-refuel can make it flyable.
await db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter', { destIcao: 'LEMD' });
const res = await post('plane-starter', { destIcao: 'LPPD' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Refill for later tests.
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
// Put the starter back at the hub for later tests.
await db.update(schema.planes).set({ locationIcao: 'EDDF' }).where(eq(schema.planes.id, 'plane-starter'));
});
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
@@ -0,0 +1,34 @@
import { cancelQueuedLeg, performAction } from '@/lib/game/actions';
// POST /api/planes/:planeId/queue/cancel (v0.25.0, ISS-043) — body { index: number }.
// Removes a NOT-yet-started leg from the plane's queue by index; only queued legs
// are cancellable, the active in-flight leg is never affected. 400 missing/
// non-integer index, unknown fields, or an index outside [0, queue.length); 404
// unknown plane id. On success returns the full state snapshot (plane.queue updated).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
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 !== 'index') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (!Number.isInteger(input.index)) {
return Response.json({ error: 'index must be an integer' }, { status: 400 });
}
const index = input.index as number;
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
}
+35
View File
@@ -0,0 +1,35 @@
import { performAction, queueLeg } from '@/lib/game/actions';
// POST /api/planes/:planeId/queue (v0.25.0, ISS-043) — body { destIcao: string }.
// Appends a NOT-yet-started leg to the plane's flight queue; works whether the
// plane is grounded or flying (pre-building a route). Nothing auto-starts here —
// queued legs chain on landing when fuel covers them. 400 missing/non-string
// destIcao, unknown fields, or an ICAO not in the airport dataset; 404 unknown
// plane id. On success returns the full state snapshot (plane.queue updated).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
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 !== 'destIcao') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.destIcao !== 'string' || input.destIcao.length === 0) {
return Response.json({ error: 'destIcao must be a non-empty string' }, { status: 400 });
}
const destIcao = input.destIcao;
return performAction((inst) => queueLeg(inst, planeId, destIcao));
}
@@ -1,161 +0,0 @@
import fs from 'node:fs/promises';
import os from 'node:os';
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 { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-refuel-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (planeId: string, body?: unknown) =>
POST(
new Request(`http://localhost/api/planes/${planeId}/refuel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
}),
{ params: Promise.resolve({ planeId }) },
);
describe('POST /api/planes/:planeId/refuel', () => {
it('rejects an unknown id with 404', async () => {
const res = await post('ghost');
expect(res.status).toBe(404);
});
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
const { db } = await getGameDb();
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
const res = await post('plane-starter');
expect(res.status).toBe(200);
const body = (await res.json()) as {
account: { cash: number };
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
};
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
});
it('rejects a flying plane with 409', async () => {
const { db } = await getGameDb();
await db.update(schema.planes)
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter');
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
// Ground it again for later tests.
await db.update(schema.planes)
.set({ status: 'available', departureSimTime: null, destIcao: null })
.where(eq(schema.planes.id, 'plane-starter'));
});
it('rejects the fill-up with 409 when cash is below the cost', async () => {
const { db } = await getGameDb();
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
const res = await post('plane-starter');
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
});
it('accepts an explicit {mode:"full"} body exactly like no body', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter', { mode: 'full' });
expect(res.status).toBe(200);
const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> };
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
});
it('rejects an unknown mode with 400', async () => {
const res = await post('plane-starter', { mode: 'half' });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/mode/);
});
it('rejects forFlight without a packageId with 400', async () => {
const res = await post('plane-starter', { mode: 'forFlight' });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/packageId/);
});
it('rejects an unknown body field with 400', async () => {
const res = await post('plane-starter', { liters: 100 });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/);
});
it('rejects invalid JSON with 400', async () => {
const res = await POST(
new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }),
{ params: Promise.resolve({ planeId: 'plane-starter' }) },
);
expect(res.status).toBe(400);
});
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
await db.insert(schema.packages).values({
id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
});
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
account: { cash: number };
planes: Array<{ id: string; fuel: number }>;
};
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
// Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
});
});
-47
View File
@@ -1,47 +0,0 @@
import { performAction, refuelPlane } from '@/lib/game/actions';
// POST /api/planes/:planeId/refuel (v0.8.0; optional body v0.16.0) — fills a grounded
// plane at the fuel price per added liter and deducts the cost from cash. The body is
// OPTIONAL: no body, {} or {mode:'full'} fill to maxFuel (the original behavior);
// {mode:'forFlight', packageId} adds just enough for that loaded package's destination
// leg. 400 bad body (unknown field, unknown mode, forFlight without packageId); 404
// unknown plane/package id; 409 if the plane is flying, the package isn't loaded on it
// (or is expired), the leg doesn't fit a full tank, or cash is insufficient. On success
// returns the full state snapshot (fuel raised by exactly the added liters).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
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 !== 'mode' && key !== 'packageId') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const mode = input.mode ?? 'full';
if (mode !== 'full' && mode !== 'forFlight') {
return Response.json({ error: 'mode must be "full" or "forFlight"' }, { status: 400 });
}
let packageId: string | undefined;
if (input.packageId !== undefined) {
if (typeof input.packageId !== 'string' || input.packageId.length === 0) {
return Response.json({ error: 'packageId must be a non-empty string' }, { status: 400 });
}
packageId = input.packageId;
}
if (mode === 'forFlight' && !packageId) {
return Response.json({ error: 'packageId is required when mode is "forFlight"' }, { status: 400 });
}
return performAction((inst) => refuelPlane(inst, planeId, { mode, packageId }));
}
+12
View File
@@ -0,0 +1,12 @@
import { performAction, repairPlane } from '@/lib/game/actions';
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
// "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
// the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
// 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 }> }) {
const { planeId } = await ctx.params;
return performAction((inst) => repairPlane(inst, planeId));
}
+17
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { POST } from './route';
let dir: string;
@@ -84,6 +86,21 @@ describe('POST /api/sim', () => {
await post({ timeScale: 1, paused: false }); // restore
});
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
const row = async () => (await inst.db.select().from(schema.simState))[0];
await post({ timeScale: 250 });
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
await post({ paused: true });
expect((await row()).paused).toBe(true);
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
await post({ timeScale: 1, paused: false }); // restore
expect((await row()).lastSelectedTimeScale).toBe(1);
});
it('rejects invalid input with 400', async () => {
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await post(body);
+15 -2
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route';
@@ -58,9 +60,20 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key);
}
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
// fixture airports' coordinates for every package in the snapshot.
const fc = await loadAirportsGeoJson();
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
for (const pkg of packages) {
const from = coords.get(String(pkg.originIcao));
const to = coords.get(String(pkg.destIcao));
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
}
});
it('is stable across repeated calls (same seeded DB)', async () => {
@@ -86,7 +99,7 @@ describe('GET /api/state', () => {
expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh);
expect(entry.capacity).toBe(type.capacity); // v0.12.0 slot capacity (ATR 72-600: 6)
expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
}
// Economy constants — the single source of truth stays lib/game/catalog.ts.
+58
View File
@@ -0,0 +1,58 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.31.0, ISS-055) — grants cash so the e2e suite can afford a second
// plane (ATR €200k vs the €20k starting balance). Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/create-package — same gate, same conventions.
//
// POST /api/test/add-cash
// body { amount?: number } — a positive finite number when provided; defaults to €1M.
// Adds to account.cash (rounded to cents) and returns the full state snapshot.
const DEFAULT_AMOUNT = 1_000_000;
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let amount = DEFAULT_AMOUNT;
if (input.amount !== undefined) {
if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) {
return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 });
}
amount = input.amount;
}
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 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+21 -2
View File
@@ -14,11 +14,16 @@ import { tickNow } from '@/lib/sim/engine';
// same gate, same conventions.
//
// POST /api/test/create-package
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number }
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generatePackage.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// hook deadline of expire-package) — pass an explicit value to override.
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
// flows that load several packages can stay under the plane's payload deterministically.
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
// e2e specs that assert exact reward/rep values stay deterministic.
// Returns 201 + the created package row.
export async function POST(req: Request) {
@@ -38,7 +43,7 @@ export async function POST(req: Request) {
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
@@ -50,6 +55,12 @@ export async function POST(req: Request) {
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
}
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
}
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
}
try {
const inst = await getGameDb();
@@ -71,6 +82,10 @@ 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 created = generatePackage({
id: randomUUID(),
airports,
@@ -79,6 +94,10 @@ export async function POST(req: Request) {
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
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,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+2 -1
View File
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
// 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 });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
+54
View File
@@ -0,0 +1,54 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
// stays for relative grants, this one is exact.
//
// POST /api/test/set-cash
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
// account.cash to exactly that value and returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const amount = input.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.42.0, ISS-082) — lets the e2e suite force a plane's fuel level now that the
// manual /refuel endpoint is gone (dispatch auto-refuels instead). Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here. Sibling of
// /api/test/set-health — same gate, same conventions.
//
// POST /api/test/set-fuel
// body { planeId: string, fuel: number } — both required; planeId must name an existing plane
// (404 otherwise) and fuel a finite number in [0, the plane's maxFuel] (400 outside). Sets the
// planes row's fuel directly and returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'planeId' && key !== 'fuel') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const planeId = input.planeId;
if (typeof planeId !== 'string' || planeId.length === 0) {
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
}
const fuel = input.fuel;
if (typeof fuel !== 'number' || !Number.isFinite(fuel)) {
return Response.json({ error: 'fuel is required and must be a finite number' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
if (!rows[0]) {
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
}
if (fuel < 0 || fuel > rows[0].maxFuel) {
return Response.json({ error: `fuel must be in [0, maxFuel] — this plane's tank holds ${rows[0].maxFuel} L` }, { status: 400 });
}
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an
// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other
// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions.
//
// POST /api/test/set-health
// body { planeId: string, health?: number, repairUntilMs?: number }
// - planeId is required and must name an existing plane (404 otherwise);
// - at least one of health / repairUntilMs must be present;
// - health must be a finite number in [0, 100];
// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package
// near-term-gate precedent) so the next advancing tick flips a shortened repair back.
// Returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const planeId = input.planeId;
if (typeof planeId !== 'string' || planeId.length === 0) {
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
}
let health: number | undefined;
if (input.health !== undefined) {
if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) {
return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 });
}
health = input.health;
}
let repairUntilMs: number | undefined;
if (input.repairUntilMs !== undefined) {
if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) {
return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 });
}
repairUntilMs = input.repairUntilMs;
}
if (health === undefined && repairUntilMs === undefined) {
return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 });
}
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before we set the gate (same as siblings)
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
if (!rows[0]) {
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
}
const sets: Partial<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
if (health !== undefined) sets.health = health;
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());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+249 -8
View File
@@ -48,6 +48,16 @@ body {
color: #94a3b8;
}
/* ISS-080 (v0.40.0): the version tag links to the repo's CHANGELOG.md — keep it subtle, like the rest of the header. */
.app-header a {
color: #7dd3fc;
text-decoration: none;
}
.app-header a:hover {
text-decoration: underline;
}
/* Style switcher — kept subtle, like the version tag. */
.style-switch {
font-size: 12px;
@@ -68,10 +78,46 @@ body {
color: #cbd5e1;
}
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
/* ISS-010 (v0.18.0) — Relocate row inside the airport popup (plain HTML, not React). */
.popup-relocate {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.popup-relocate select {
font-size: 12px;
padding: 3px 6px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
}
.popup-relocate button {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
cursor: pointer;
}
.popup-relocate button:disabled {
opacity: 0.5;
cursor: default;
}
[data-testid='airport-popup-relocate-status'] {
font-size: 11px;
color: #334155;
}
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market.
ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section).
ISS-046 (v0.27.1): right widened to match left at 360px — total fixed width 720px, map column shrinks by 80px. */
.app-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 360px;
grid-template-columns: 360px minmax(0, 1fr) 360px;
height: 100%;
}
@@ -178,6 +224,26 @@ body {
color: #bfdbfe;
}
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
.status-broken {
background: rgba(239, 68, 68, 0.25);
color: #fecaca;
}
.status-repairing {
background: rgba(245, 158, 11, 0.25);
color: #fde68a;
}
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
.special-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
}
.fuel-bar {
height: 4px;
border-radius: 2px;
@@ -192,6 +258,26 @@ body {
background: #34d399;
}
/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */
.health-bar {
height: 4px;
border-radius: 2px;
background: rgba(148, 163, 184, 0.25);
margin: 2px 0 4px;
overflow: hidden;
}
.health-bar > div {
height: 100%;
border-radius: 2px;
background: #f59e0b;
}
/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */
.repair-btn {
margin-top: 6px;
}
.plane-sub {
display: flex;
justify-content: space-between;
@@ -327,6 +413,12 @@ body {
padding: 4px 0;
}
/* ISS-010 (v0.18.0) — the Relocate row carries TWO selects, so let it wrap instead of
squeezing both below their readable width in the 360 px left pane (moved there in ISS-023 v0.21.0). */
.market-row-relocate {
flex-wrap: wrap;
}
.market-preview {
font-size: 12px;
color: #f8fafc;
@@ -334,16 +426,165 @@ body {
font-variant-numeric: tabular-nums;
}
/* v0.12.0 — on-board blocks (per plane) in the right pane */
.onboard-block {
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
a full-width fly-to button then refuel + abandon side by side below it; long destination names
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
.plane-row .contract-row {
background: transparent;
padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
display: flex;
flex-direction: column;
gap: 5px;
}
.plane-row .contract-row .contract-sub {
margin-top: 0; /* row spacing comes from the container's gap */
}
.onboard-fly-btn {
width: 100%;
}
.onboard-fly-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.onboard-actions {
display: flex;
gap: 6px;
}
/* ISS-043 (v0.26.0) — flight-queue sub-section inside on-board blocks */
.queue-subsection {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
display: flex;
flex-direction: column;
gap: 5px;
}
.queue-heading {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
.queue-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
.toasts {
position: fixed;
bottom: 12px;
/* 360px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
right: 372px;
z-index: 50; /* above map popups/controls and the app header */
display: flex;
flex-direction: column;
gap: 8px;
max-width: 340px;
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
background: rgba(15, 23, 42, 0.95);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.4);
}
.toast-error {
border-color: rgba(239, 68, 68, 0.7);
color: #fecaca;
}
.toast-info {
border-color: rgba(148, 163, 184, 0.5);
}
.toast-close {
flex-shrink: 0;
font-size: 14px;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.toast-close:hover {
color: #e2e8f0;
}
/* ISS-008 — the Time section's notifications opt-in button (same look as .time-controls buttons) */
.notif-toggle {
margin-top: 8px;
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.notif-toggle:disabled {
opacity: 0.5;
cursor: default;
}
/* v0.28.0 (ISS-047) — right pane tabs (Jobs / Planes market) + per-type tech-spec cards */
.pane-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.tab-btn[aria-selected='true'] {
background: rgba(56, 189, 248, 0.25);
border-color: rgba(56, 189, 248, 0.7);
color: #f8fafc;
}
.plane-type-card {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.onboard-block .contract-row {
background: transparent;
padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
.plane-type-name {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
+32 -5
View File
@@ -1,11 +1,14 @@
'use client';
import dynamic from 'next/dynamic';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane';
import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes';
import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
@@ -19,12 +22,36 @@ export default function Home() {
// either pane highlights the matching plane/route/airports on the map.
const [hovered, setHovered] = useState<HoverTarget | null>(null);
// ISS-008 (v0.17.0): bottom-right toasts — action errors from both panes plus game events
// derived by diffing consecutive snapshots (no server event feed exists).
const { toasts, push, dismiss } = useToasts();
const onError = (message: string) => push('error', message);
// Diff each new snapshot against the previous one → info toasts. When the window is hidden
// and notifications are granted, mirror them as browser notifications too.
const prevSnapshotRef = useRef<typeof state>(null);
useEffect(() => {
if (state === null) return;
for (const event of diffSnapshots(prevSnapshotRef.current, state)) {
push('info', event.message);
if (document.hidden && 'Notification' in window && Notification.permission === 'granted') {
new Notification('Flight Ops', { body: event.message });
}
}
prevSnapshotRef.current = state;
}, [state, push]);
return (
<div className="app-shell">
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} onHover={setHovered} />
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */}
<Map state={state} hovered={hovered} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} />
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
fly/abandon actions push their response snapshot into the shared state */}
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
{/* 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} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<ToastStack toasts={toasts} onDismiss={dismiss} />
</div>
);
}
+412 -49
View File
@@ -1,10 +1,15 @@
'use client';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
import { cargoWeightFor } from './RightPane';
import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps {
@@ -13,8 +18,13 @@ interface LeftPaneProps {
error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
onError?: (message: string) => void;
}
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
@@ -29,13 +39,12 @@ function planeLocation(p: PlaneState): string {
return p.locationName;
}
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot.
// Cargo loadout "X.Xt / Y.Yt" (v0.27.0) — used/max payload in tonnes, one decimal;
// expired packages still occupy their payload.
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
).length;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
return `${used}/${capacity} pkgs`;
const used = cargoWeightFor(state, p.id);
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
}
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
@@ -45,16 +54,83 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca
return pendingScale !== null && serverTimeScale === pendingScale;
}
export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) {
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
export const SIM_POST_MAX_ATTEMPTS = 3;
export const SIM_POST_RETRY_DELAY_MS = 500;
export type SimPostRetryResult =
| { ok: true }
| { ok: false; reason: 'exhausted'; error: unknown }
| { ok: false; reason: 'superseded' };
export async function retrySimPost(
post: () => Promise<void>,
superseded: () => boolean,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
delayMs: number = SIM_POST_RETRY_DELAY_MS,
): Promise<SimPostRetryResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
try {
await post();
return { ok: true };
} catch (err) {
lastError = err;
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
if (attempt < maxAttempts) await sleep(delayMs);
}
}
return { ok: false, reason: 'exhausted', error: lastError };
}
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
// controls without waiting for the next poll tick. On failure keep the stale UI — the
// running poll retries on its own and surfaces the error state. A commit that lands while
// another POST is in flight is held in a single slot (last intent wins) and flushed by the
// in-flight call, so every user intent reaches the server (the [1,1000] clamp stays as backstop).
// The gate is a REF, not React state: closures never see a stale "busy" value, which used to
// let a flush re-queue instead of send and silently drop a commit.
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
// "effective selection" pattern as there: the user's pick while it stays valid, else a sensible
// default (first grounded plane / first airport).
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
// from RightPane in ISS-023 (v0.21.0) — the relocate-dest select was its only consumer there.
const [airportOptions, setAirportOptions] = useState<Array<{ name: string; icao: string }> | null>(null);
const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | null>(null);
useEffect(() => {
if (!airportsPromiseRef.current) {
airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' })
.then((res) => {
if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
return res.json() as Promise<GeoJsonFeatureCollection>;
})
.then((fc) =>
fc.features
.map((f) => ({ name: f.properties.name, icao: f.properties.icao }))
.sort((a, b) => a.name.localeCompare(b.name, 'en')),
);
}
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
}, []);
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
// toast (the running poll retries on its own). A commit that lands while another POST is in
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
// which used to let a flush re-queue instead of send and silently drop a commit.
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
const busyRef = useRef(false);
@@ -75,20 +151,32 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
try {
let current: { timeScale?: number; paused?: boolean } | null = body;
while (current !== null) {
const res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
onRefresh();
if (current === body) sentOwn = true; // object identity — queued bodies are fresh literals
const target = current; // stable across this retry round
const result = await retrySimPost(
async () => {
const res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
},
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
);
if (result.ok) {
onRefresh();
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
} else if (result.reason === 'exhausted') {
throw result.error; // only after the final attempt fails → error toast below
}
current = queuedRef.current; // pick up anything that arrived while we were in flight
queuedRef.current = null;
}
} catch (err) {
console.error('failed to apply sim controls', err);
// ISS-008: surface the failure as an error toast too — previously console-only.
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
@@ -96,12 +184,30 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh],
[onRefresh, onError],
);
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
const clockMs = state?.simState.clockMs ?? 0;
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
// market row; with state null both fall back to null and the section isn't rendered.
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
// queued instead of started immediately (see relocate-go below).
const relocatable = state ? state.planes : [];
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
const relocateDest =
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
// ISS-077 (v0.41.0): while flying, the Relocate click would POST /queue — block it when the selected
// destination is already active or queued (the server rejects that with 409). Grounded planes are unaffected.
const relocateBlocked =
relocatePlane?.status === 'flying' && relocateDest !== null &&
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
// user releases the slider (browsers fire a native 'change' on release). The server's
@@ -165,8 +271,33 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
[],
);
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
// in an effect after hydration, so server and client render identically) and follows the
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
const [notifPermission, setNotifPermission] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) setNotifPermission(Notification.permission);
else setNotifPermission('unsupported');
}, []);
// null (pre-effect) and 'default' both read as the opt-in prompt.
const notifLabel: string =
notifPermission === 'granted'
? 'Notifications enabled'
: notifPermission === 'denied'
? 'Notifications blocked'
: notifPermission === 'unsupported'
? 'Notifications unavailable'
: 'Enable notifications';
const displayedScale = pendingScale ?? timeScale;
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
// penalty previews stay snapshot-based (deterministic against the server clock).
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
return (
<aside data-testid="left-pane" className="pane left-pane">
<h2 className="pane-title">Assets</h2>
@@ -191,37 +322,249 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
<p className="pane-muted">No planes.</p>
) : (
<ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => (
// ISS-002: hovering a fleet row highlights the plane on the map.
<li
key={p.id}
data-testid={`plane-${p.id}`}
className="plane-row"
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
onMouseLeave={() => onHover(null)}
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
<span className={`plane-status status-${p.status}`}>{p.status}</span>
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
</li>
))}
{state.planes.map((p) => {
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
// On board section's cargo rows and queue legs, so all plane info lives in one place.
const cargo = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
// the whole block without flicker between sub-elements; cargo rows override with their
// own package hover (below).
<li
key={p.id}
data-testid={`plane-${p.id}`}
className="plane-row"
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
onMouseLeave={() => onHover(null)}
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
{p.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{p.destName && (
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
{p.locationName} → {p.destName}
</span>
)}
</>
) : (
<>
<span className="plane-status status-available">{p.locationIcao}</span>
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
{p.status === 'repairing' && (
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
)}
{p.health <= 0 && (
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
)}
</>
)}
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
<div className="health-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
{p.status === 'available' && p.health < 100 && (
<button
type="button"
data-testid={`repair-${p.id}`}
className="pane-btn repair-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
>
Repair
</button>
)}
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
// ISS-077 (v0.41.0): while flying the click would POST /queue — disable it when the
// destination is already active or queued (the server rejects that with 409). Grounded
// clicks go to /fly, which has no such conflict.
const alreadyQueued = p.status === 'flying' && (p.destIcao === pkg.destIcao || p.queue.includes(pkg.destIcao));
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy || alreadyQueued}
title={alreadyQueued ? 'Already in flight queue' : undefined}
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
// so "Fly to …" works from any state and the queue needs no dedicated input.
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
Fly to {pkg.destIcao}
</span>
</button>
{/* ISS-082 (v0.42.0): the per-leg Refuel button is gone — dispatch auto-refuels. */}
<div className="onboard-actions">
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao}
</span>
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
/queue while a plane is flying, so no separate entry point is needed.
ISS-054 (v0.33.0): rendered only when non-empty. */}
{p.queue.length > 0 && (
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
duplicated in the DOM and broke Playwright strict mode. */}
{p.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${p.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between Fleet and Time. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target.
With no planes or no loaded airport options there is nothing to preview → null. */}
<section
className="pane-section"
data-testid="relocate-section"
onMouseEnter={() =>
onHover(
relocatePlane && relocateDest
? { kind: 'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao }
: null,
)
}
onMouseLeave={() => onHover(null)}
>
<h3 className="pane-heading">Relocate</h3>
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board.
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
<div className="market-row market-row-relocate">
<select
data-testid="relocate-plane"
aria-label="Plane to relocate"
value={relocatePlane?.id ?? ''}
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
>
{relocatable.map((p) => (
<option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
</option>
))}
</select>
<select
data-testid="relocate-dest"
aria-label="Relocation destination airport"
value={relocateDest?.icao ?? ''}
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
>
{airportOptions?.map((a) => (
// ISS-077 (v0.41.0): a flying plane's active/queued airports can't be re-selected — the
// server would reject them with 409; native disabled options, no text change.
<option key={a.icao} value={a.icao} disabled={relocatePlane?.status === 'flying' && (relocatePlane.destIcao === a.icao || relocatePlane.queue.includes(a.icao))}>
{a.name}
</option>
))}
</select>
<button
type="button"
data-testid="relocate-go"
className="pane-btn"
disabled={actionsBusy || relocatePlane === null || relocateDest === null || relocateBlocked}
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
// relocation takes off when the current one lands.
onClick={() => {
if (relocatePlane && relocateDest) {
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
}
}}
>
Relocate
</button>
</div>
</section>
{/* ISS-082 (v0.42.0): the "Refuel to full" section is gone — dispatch auto-refuels instead. */}
<section className="pane-section">
<h3 className="pane-heading">Time</h3>
<p data-testid="sim-clock" className="sim-clock">
{formatSimClock(state.simState.clockMs)}
{simClockMs !== null && formatSimClock(simClockMs)}
{paused ? ' (paused)' : ''}
</p>
<div className="time-controls">
@@ -246,6 +589,24 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button
type="button"
data-testid="enable-notifications"
className="notif-toggle"
disabled={notifPermission === 'unsupported'}
title={notifPermission === 'unsupported' ? 'This browser does not support notifications' : undefined}
onClick={() => {
// A rejection (rare — e.g. some headless setups) reads as "blocked".
if ('Notification' in window) {
void Notification.requestPermission()
.then(setNotifPermission)
.catch(() => setNotifPermission('denied'));
}
}}
>
{notifLabel}
</button>
</section>
{error && (
@@ -253,6 +614,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
{error}
</p>
)}
{/* ISS-021 (v0.20.0): inline error from a failed On board action — mirrors right-error. */}
{actionError && <p data-testid="left-action-error" className="pane-error">{actionError}</p>}
</>
) : null}
</aside>
+197 -13
View File
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
import type { GeoJsonFeatureCollection } from '../lib/airports';
import type { StateSnapshot } from '../lib/game/state';
import type { HoverTarget } from './hoverTypes';
import { planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
@@ -28,11 +28,24 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
const PLANES_FLYING_LAYER_ID = 'planes-flying';
const ROUTES_SOURCE_ID = 'routes';
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
// ISS-043 (v0.26.0): the dotted queue line — a flying plane's NOT-yet-started legs
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight';
const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-highlight';
// ISS-022/025/027 (v0.22.0): the hover route trace — its OWN source + top-most dashed line,
// because the shared `routes` source only holds FLYING legs: an open package's or a grounded
// relocation's planned route is never in it. applyHoverRef pushes data into it on every hover.
const HOVER_ROUTES_SOURCE_ID = 'hover-routes';
const HOVER_ROUTE_LAYER_ID = 'hover-route';
// ISS-022 (v0.22.0): the DESTINATION airport's ring — a second, differently-colored circle over
// the same `airports` source so the target stands out from the origin's plain yellow ring.
// applyHoverRef filters it to the hovered target's destination icao only.
const AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID = 'airports-target-highlight';
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -43,13 +56,17 @@ interface MapProps {
// route lines. Null until the first successful poll.
state?: StateSnapshot | null;
// ISS-002 (v0.15.0): the side panels' hover target — highlights the matching plane
// (+ its route leg if flying) or a package's endpoint airports. Null = nothing hovered.
// (+ its route leg if flying) or a package's endpoint airports, and (ISS-022/025/027 v0.22.0)
// draws the full great-circle trace between any hovered target's two endpoints. Null = nothing hovered.
hovered?: HoverTarget | null;
// 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;
}
// 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 }: MapProps) {
export default function FlightMap({ state, hovered, applySnapshot }: 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
@@ -58,6 +75,12 @@ export default function FlightMap({ state, hovered }: MapProps) {
// Latest snapshot + airport coordinate index, so the mount effect's styledata wiring
// and the state-change effect can both push data without prop-drilling into closures.
const stateRef = useRef<StateSnapshot | null>(null);
// ISS-010 (v0.18.0): the shell's applySnapshot is stable (useCallback with no deps), but the
// mount effect captures it once — keep a ref so the popup wiring always calls the latest one.
const applySnapshotRef = useRef(applySnapshot);
useEffect(() => {
applySnapshotRef.current = applySnapshot;
}, [applySnapshot]);
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null);
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
@@ -76,11 +99,19 @@ export default function FlightMap({ state, hovered }: MapProps) {
if (routesSource && airportsIndexRef.current) {
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
}
// ISS-043 (v0.26.0): the dotted queue line — same airport-index guard as the solid legs above.
const queueRoutesSource = map.getSource(QUEUE_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (queueRoutesSource && airportsIndexRef.current) {
void queueRoutesSource.setData(queuedRoutesGeoJson(st.planes, airportsIndexRef.current));
}
};
useEffect(() => {
stateRef.current = state ?? null;
applyDynamicRef.current();
// ISS-040 (v0.23.2): re-apply the hover too, so an on-board package's trace follows a moving
// carrier between polls while hovered — idempotent and cheap (one small setData + ring filters).
applyHoverRef.current();
}, [state]);
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
@@ -92,22 +123,66 @@ export default function FlightMap({ state, hovered }: MapProps) {
const map = mapRef.current;
if (!map) return;
const h = hoveredRef.current;
// The empty-string sentinel matches nothing: no feature has id/icao ''.
const planeId = h && h.kind === 'plane' ? h.id : '';
// The empty-string sentinel matches nothing: no feature has id/icao ''. A package hover may
// name its plane (ISS-025 on-board cargo rows), so that plane's halo stays lit too.
let planeId = '';
let fromIcao = '';
let toIcao = '';
if (h) {
if (h.kind === 'plane') planeId = h.id;
else if (h.kind === 'package') {
planeId = h.planeId ?? '';
fromIcao = h.originIcao;
toIcao = h.destIcao;
} else {
// 'route' — the Relocate panel's planned flight: trace + target airport only.
fromIcao = h.fromIcao;
toIcao = h.toIcao;
}
}
if (map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(PLANES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'id'], planeId]);
}
if (map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(ROUTES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'planeId'], planeId]);
}
const originIcao = h && h.kind === 'package' ? h.originIcao : '';
const destIcao = h && h.kind === 'package' ? h.destIcao : '';
// ISS-022/025/027: the hover route trace — one great-circle line for any target carrying two
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (hoverSource) {
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
// not its origin airport — identical to before while grounded at the pickup, different once it
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
// ISS-077 (v0.41.0): a FLYING carrier's trace starts at its LANDING airport (destIcao), not the
// live mid-flight position — the hover previews what happens AFTER this leg lands, and the solid
// active-leg + dotted queue layers already show where it is now. Grounded / no active leg keeps
// today's behavior exactly (live position = the plane's airport). destIcao missing from the
// index → fall back to the live position rather than guessing.
const idx = airportsIndexRef.current ?? new Map();
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
const landing = carrier?.status === 'flying' && carrier.destIcao ? idx.get(carrier.destIcao) : undefined;
void hoverSource.setData(
landing
? hoverTraceGeoJson(landing, toIcao, idx)
: carrier
? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx)
: hoverRouteGeoJson(fromIcao, toIcao, idx),
);
}
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
const originIcao = h?.kind === 'package' ? fromIcao : '';
const destIcao = h?.kind === 'package' || h?.kind === 'route' ? toIcao : '';
if (map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) {
map.setFilter(
AIRPORTS_HIGHLIGHT_LAYER_ID,
['any', ['==', ['get', 'icao'], originIcao], ['==', ['get', 'icao'], destIcao]],
);
}
// ISS-022: the destination gets its own distinct-colored ring on top (package AND route hovers).
if (map.getLayer(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID)) {
map.setFilter(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID, ['==', ['get', 'icao'], destIcao]);
}
};
useEffect(() => {
@@ -156,16 +231,67 @@ export default function FlightMap({ state, hovered }: MapProps) {
// deterministic generated weather — no gameplay effects per PLAN.md.
const w = weatherFor(p.icao);
const localTime = approxLocalTime(p.lon, Date.now());
new maplibregl.Popup({ offset: 10 })
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
const st = stateRef.current;
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
const relocateHtml =
groundedPlanes.length > 0
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
`<option value="">Select a plane\u2026</option>` +
groundedPlanes.map(
(pl) =>
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
).join('') +
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
const popup = new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number])
.setHTML(
`<div data-testid="airport-popup">` +
`<strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}<br/>` +
`<span data-testid="airport-local-time">${localTime} local (approx)</span><br/>` +
`<span data-testid="airport-weather">${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m</span>` +
`<div class="popup-relocate">` + relocateHtml + `<span data-testid="airport-popup-relocate-status"></span></div>` +
`</div>`,
)
.addTo(map);
// Popups are plain HTML, not React — the element only exists after addTo, so wire the
// controls via querySelector (the select enables the button once a plane is picked).
const el = popup.getElement();
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
// The status span is always in the popup HTML (even with no grounded planes), so it
// exists whenever the select/button do.
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
if (select && button && statusEl) {
select.addEventListener('change', () => {
button.disabled = select.value === '';
});
button.addEventListener('click', async () => {
const planeId = select.value;
if (!planeId || button.disabled) return;
button.disabled = true;
statusEl.textContent = 'Relocating\u2026';
try {
const res = await fetch(`/api/planes/${planeId}/fly`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destIcao: p.icao }),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
statusEl.textContent = 'Relocation started.';
} catch (err) {
statusEl.textContent = err instanceof Error ? err.message : String(err);
button.disabled = false; // allow retry / picking another plane
}
});
}
};
const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => {
@@ -224,9 +350,9 @@ export default function FlightMap({ state, hovered }: MapProps) {
type: 'circle',
source: AIRPORTS_SOURCE_ID,
paint: {
'circle-radius': 3,
'circle-radius': 5, // ISS-010 (v0.18.0): bigger — the r=3 dots were hard to click
'circle-color': '#e11d48',
'circle-stroke-width': 1,
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff',
},
});
@@ -250,6 +376,27 @@ export default function FlightMap({ state, hovered }: MapProps) {
},
});
}
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson',
data: queuedRoutesGeoJson(stateRef.current?.planes ?? [], airportsIndexRef.current ?? new Map()),
});
}
if (!map.getLayer(QUEUE_ROUTES_LAYER_ID)) {
map.addLayer({
id: QUEUE_ROUTES_LAYER_ID,
type: 'line',
source: QUEUE_ROUTES_SOURCE_ID,
paint: {
'line-color': '#64748b', // muted slate — planned legs, not actively flying
'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
},
});
}
if (!map.getSource(PLANES_SOURCE_ID)) {
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
}
@@ -317,13 +464,48 @@ export default function FlightMap({ state, hovered }: MapProps) {
source: AIRPORTS_SOURCE_ID,
filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']],
paint: {
'circle-radius': 8, // ring around the base airport circles (r=3)
'circle-radius': 9, // ring around the base airport circles (r=5)
'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring
'circle-stroke-width': 2.5,
'circle-stroke-color': '#facc15',
},
});
}
// ISS-022/025/027 (v0.22.0): the hover route trace — added LAST so it sits above every
// other layer, including the highlight rings at its endpoints. Own source: the shared
// `routes` source only holds flying legs, never a planned one. Empty until a hover sets data.
if (!map.getSource(HOVER_ROUTES_SOURCE_ID)) {
map.addSource(HOVER_ROUTES_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
}
if (!map.getLayer(HOVER_ROUTE_LAYER_ID)) {
map.addLayer({
id: HOVER_ROUTE_LAYER_ID,
type: 'line',
source: HOVER_ROUTES_SOURCE_ID,
paint: {
'line-color': '#facc15', // matches the airports-highlight ring — one "trace" color
'line-width': 4,
'line-dasharray': [2, 2], // dashed = planned route, distinct from solid flying legs
},
});
}
// ISS-022 (v0.22.0): the destination's ring — same geometry as airports-highlight but a
// DIFFERENT color so the target stands out over the origin's yellow ring; added last so it
// also sits above the hover-route line at its endpoint. Starts filtered to match nothing.
if (!map.getLayer(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID)) {
map.addLayer({
id: AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID,
type: 'circle',
source: AIRPORTS_SOURCE_ID,
filter: ['==', ['get', 'icao'], ''],
paint: {
'circle-radius': 9, // same ring size as airports-highlight (r=5 base circles)
'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring
'circle-stroke-width': 3,
'circle-stroke-color': '#4ade80', // green — distinct from the yellow endpoint rings
},
});
}
// Layer-scoped listeners survive setStyle (they filter by layer id at event
// time), so off+on with the same reference re-wires without duplicating.
map.off('click', AIRPORTS_LAYER_ID, onAirportClick);
@@ -365,7 +547,9 @@ export default function FlightMap({ state, hovered }: MapProps) {
<div className="map-wrap">
<header data-testid="app-header" className="app-header">
<h1>Flight Ops</h1>
<small>v{pkg.version}</small>
<a data-testid="app-version" href="https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md" target="_blank" rel="noreferrer">
<small>v{pkg.version}</small>
</a>
<select
data-testid="style-switch"
className="style-switch"
@@ -380,7 +564,7 @@ export default function FlightMap({ state, hovered }: MapProps) {
))}
</select>
<span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`}
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span>
</header>
<div ref={containerRef} data-testid="map" className="map-container" />
+135 -193
View File
@@ -1,10 +1,11 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
interface RightPaneProps {
@@ -16,35 +17,41 @@ interface RightPaneProps {
// ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or
// the package's endpoint airports.
onHover: (target: HoverTarget | null) => void;
// ISS-008 (v0.17.0): report a failed action to the shell as an error toast — in ADDITION
// to the inline pane error, which stays (both surfaces by design).
onError?: (message: string) => void;
}
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which
// keeps its slot until it is abandoned or the plane lands at its destination.
export function slotsUsedFor(state: StateSnapshot, planeId: string): number {
return state.packages.filter(
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'),
).length;
// Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
// cargo, which keeps its space until it is abandoned or the plane lands at its
// destination.
export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
return state.packages
.filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'))
.reduce((sum, p) => sum + p.weightKg, 0);
}
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a
// free slot (capacity from the snapshot catalog). The Load button is enabled iff
// this list is non-empty; exactly one entry loads directly, several show a picker.
// Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
// payload room for it (maxPayloadKg from the snapshot catalog). The Load button is
// enabled iff this list is non-empty; exactly one entry loads directly, several show a picker.
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
return state.planes.filter((p) => {
if (p.status !== 'available') return false;
if (p.locationIcao !== pkg.originIcao) return false;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0;
return slotsUsedFor(state, p.id) < capacity;
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
// Mirrors the server check in lib/game/actions.ts loadPackage.
return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg;
});
}
// Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its
// selection in local state and defaults to the "Select a plane…" placeholder —
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
// selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
// first eligible plane so Load is one-click out of the box (matching the single-plane
// UX) while the picker still allows choosing a different plane.
function LoadControls({
pkg,
eligible,
@@ -57,8 +64,9 @@ function LoadControls({
onLoad: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// The user's pick while it stays eligible, else nothing (placeholder).
const effective = eligible.find((p) => p.id === selectedId) ?? null;
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
if (eligible.length === 1) {
return (
@@ -104,48 +112,23 @@ function LoadControls({
);
}
export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) {
const [busy, setBusy] = useState(false);
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
// snapshot prop changes every tick) or the next successful action.
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) {
// ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on
// failure, full post-action snapshot pushed into the shared state on success.
const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError);
// Market picker selections — same "effective selection" pattern as LoadControls: the
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
// inline pane error and the stale UI stays until the next poll. Buttons are disabled while
// a request is in flight, so at most one action runs at a time.
const runAction = useCallback(
async (path: string, body?: unknown) => {
if (busy) return;
setBusy(true);
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setActionError(null);
applySnapshot(data);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
},
[applySnapshot, busy],
);
// v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
// about our planes, not in the market. Its state + /api/airports fetch went with it.
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
// left pane's "Refuel" section next to Relocate.
if (loading && !state) {
return (
@@ -160,33 +143,47 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
const clockMs = state.simState.clockMs;
const open = state.packages.filter((p) => p.status === 'open');
// On board (v0.12.0): every plane that carries cargo or is mid-flight gets a block —
// its loaded/expired packages with fly-to-destination + abandon controls, and flying
// planes show their current leg.
const onboard = state.planes.filter(
(p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0,
);
const catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
refuelable[0] ??
null;
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
{/* v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. */}
<div className="pane-tabs" role="tablist">
<button
type="button"
data-testid="tab-jobs"
role="tab"
aria-selected={activeTab === 'jobs'}
className="tab-btn"
onClick={() => setActiveTab('jobs')}
>
Jobs
</button>
<button
type="button"
data-testid="tab-planes-market"
role="tab"
aria-selected={activeTab === 'planes'}
className="tab-btn"
onClick={() => setActiveTab('planes')}
>
Planes market
</button>
</div>
{activeTab === 'jobs' && (
<section className="pane-section">
<h3 className="pane-heading">Packages</h3>
{open.length === 0 ? (
@@ -206,6 +203,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
>
<div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
{eligible.length > 0 ? (
<LoadControls
pkg={pkg}
@@ -218,10 +221,23 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
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>
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span>
@@ -232,94 +248,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
</ul>
)}
</section>
)}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{planeName(plane)}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
<div key={pkg.id} data-testid={`onboard-package-${pkg.id}`} className="contract-row">
<div className="contract-main">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
>
Fly to {pkg.destName}
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
</div>
);
})
)}
</section>
{/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
{activeTab === 'planes' && (
<section className="pane-section">
<h3 className="pane-heading">Market</h3>
@@ -337,8 +270,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
))}
</select>
<span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''}
{buyPrice !== null ? formatCash(buyPrice) : ''}
</span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{planeDiscountRate > 0 && (
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</span>
)}
<button
type="button"
data-testid="market-buy"
@@ -381,43 +318,48 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
</button>
</div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
<h4 className="plane-type-name">{e.label}</h4>
<div className="stat-row">
<span>Buy</span>
<strong>{formatCash(e.price)}</strong>
</div>
<div className="stat-row">
<span>Sell</span>
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
</div>
<div className="stat-row">
<span>Payload</span>
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
</div>
<div className="stat-row">
<span>Cruise speed</span>
<strong>{e.speedKmh} km/h</strong>
</div>
<div className="stat-row">
<span>Fuel burn</span>
<strong>{e.burnPerKm} L/km</strong>
</div>
<div className="stat-row">
<span>Max fuel</span>
<strong>{e.maxFuel} L</strong>
</div>
<div className="stat-row">
<span>Max range</span>
<strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
))}
</section>
)}
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
+24
View File
@@ -0,0 +1,24 @@
'use client';
import type { Toast } from '@/lib/useToasts';
// ISS-008 (v0.17.0): the bottom-right toast stack — action errors and game events derived
// from snapshot diffs. Fixed-position overlay above the map; each item auto-dismisses via
// the hook's TTL or its × button.
export default function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss?: (id: number) => void }) {
if (toasts.length === 0) return null;
return (
<div className="toasts" role="status">
{toasts.map((t) => (
<div key={t.id} data-testid="toast" className={`toast toast-${t.kind}`}>
<span>{t.message}</span>
{onDismiss && (
<button type="button" data-testid={`toast-dismiss-${t.id}`} className="toast-close" onClick={() => onDismiss(t.id)}>
×
</button>
)}
</div>
))}
</div>
);
}
+6 -1
View File
@@ -1,7 +1,12 @@
// ISS-002 — what a side-panel hover highlights on the map (v0.15.0): a plane marker
// (+ its route leg if flying) or both endpoint airports of an open package. The shell
// owns this state and passes it to <Map>; panes report it via their onHover callback.
// ISS-022/025/027 (v0.22.0): any target carrying two ICAOs also makes the map draw the full
// great-circle trace between them — package hovers carry origin→dest, 'route' hovers (the
// Relocate panel) carry fromIcao→toIcao; a package hover may additionally name its plane so
// that plane's halo stays lit alongside the trace.
export type HoverTarget =
| { kind: 'plane'; id: string }
| { kind: 'package'; originIcao: string; destIcao: string };
| { kind: 'package'; originIcao: string; destIcao: string; planeId?: string }
| { kind: 'route'; fromIcao: string; toIcao: string };
+69 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { pendingScaleConfirmed } from './LeftPane';
import { describe, expect, it, vi } from 'vitest';
import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane';
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
@@ -22,3 +22,70 @@ describe('pendingScaleConfirmed', () => {
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
});
});
// ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the
// error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer
// intent supersedes the in-flight body instead of waiting out its retries.
describe('retrySimPost', () => {
it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => {
expect(SIM_POST_MAX_ATTEMPTS).toBe(3);
expect(SIM_POST_RETRY_DELAY_MS).toBe(500);
});
it('succeeds on the first attempt without sleeping or retrying', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
it('retries with the backoff delay and succeeds on a later attempt', async () => {
let calls = 0;
const post = vi.fn(async () => {
if (++calls < 3) throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts
expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS);
});
it('gives up after the final attempt with the last error (no trailing sleep)', async () => {
const err = new Error('down');
const post = vi.fn(async () => {
throw err;
});
const sleep = vi.fn(async () => {});
const result = await retrySimPost(post, () => false, sleep);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('exhausted');
if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error
}
expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS);
expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1);
});
it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => {
let calls = 0;
const post = vi.fn(async () => {
calls++; // flip "superseded" right after the first failure, like a queued commitNow would
throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead
expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep
});
it('never even sends a body that is already superseded', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).not.toHaveBeenCalled();
expect(sleep).not.toHaveBeenCalled();
});
});
+33 -22
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest';
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
// it is GROUNDED at the package's origin with a free slot (capacity from the
// snapshot catalog; expired packages still occupy theirs). The Load button acts
// directly on the single eligible plane, or shows a picker when several qualify.
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
// package only while it is GROUNDED at the package's origin with enough payload room for
// it (maxPayloadKg from the snapshot catalog; expired packages still occupy theirs). The
// Load button acts directly on the single eligible plane, or shows a picker when several qualify.
const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
];
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
@@ -24,6 +24,9 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
departureSimTime: null,
lat: 50,
lon: 8.5,
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null,
queue: [],
...over,
};
}
@@ -37,6 +40,9 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
reward: 1000,
cargoType: 'mail',
cargoCount: 4,
weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 1_000_000,
status: 'open',
planeId: null,
@@ -57,27 +63,27 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
};
}
describe('slotsUsedFor', () => {
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
describe('cargoWeightFor', () => {
it('sums the weight of loaded AND expired packages bound to the plane (both occupy payload)', () => {
const s = snap(
[plane({ id: 'p1' })],
[
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
pkg({ id: 'a', status: 'loaded', planeId: 'p1', weightKg: 800 }),
pkg({ id: 'b', status: 'expired', planeId: 'p1', weightKg: 500 }),
pkg({ id: 'c', status: 'open', planeId: null, weightKg: 9_000 }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1', weightKg: 700 }), // terminal — payload long freed
],
);
expect(slotsUsedFor(s, 'p1')).toBe(2);
expect(cargoWeightFor(s, 'p1')).toBe(1_300);
});
it('is 0 for a plane with no bound cargo', () => {
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
expect(cargoWeightFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
});
});
describe('loadEligiblePlanes (Load button eligibility)', () => {
it('includes only grounded planes at the package origin with a free slot', () => {
it('includes only grounded planes at the package origin with enough payload room', () => {
const s = snap([
plane({ id: 'at-origin' }), // eligible
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
@@ -86,18 +92,23 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
});
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
const packages = Array.from({ length: 6 }, (_, i) =>
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
it('a plane with partial payload can still load a package that fits', () => {
const s = snap([plane({ id: 'partial' })], [pkg({ id: 'onboard', status: 'loaded', planeId: 'partial', weightKg: 3_000 })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['partial']); // 3000 + 1000 ≤ 4800
});
it('excludes a plane whose payload is full (maxPayloadKg from the catalog)', () => {
const packages = Array.from({ length: 4 }, (_, i) =>
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 }), // 4 × 1200 = 4800
);
const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
it('a full plane with one slot freed by an expired package still counts as full', () => {
it('a plane at its limit with an expired package still counts as full', () => {
const packages = [
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 960 })),
pkg({ id: 'exp', status: 'expired', planeId: 'full', weightKg: 960 }), // occupies its payload — 5 × 960 = 4800
];
const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
@@ -113,7 +124,7 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
it('a plane with an unknown model has no capacity → never eligible', () => {
it('a plane with an unknown model has no maxPayloadKg in the catalog → never eligible', () => {
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
-243
View File
@@ -1,243 +0,0 @@
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
117,BIAR,large_airport,Akureyri International Airport,65.656573,-18.072018,6,EU,IS,IS-6,Akureyri,yes,BIAR,AEY,BIAR,,https://www.innanlandsflugvellir.is/en/akureyri-airport/,https://en.wikipedia.org/wiki/Akureyri_Airport,
123,BIKF,large_airport,Keflavik International Airport,63.985001,-22.6056,171,EU,IS,IS-2,Reykjavík,yes,BIKF,KEF,BIKF,,https://www.isavia.is/en/keflavik-airport,https://en.wikipedia.org/wiki/Keflav%C3%ADk_International_Airport,"Keflavik Naval Air Station, REK"
4614,BKPR,large_airport,Priština Adem Jashari International Airport,42.5728,21.035801,1789,EU,XK,XK-01,Prishtina,yes,BKPR,PRN,BKPR,,http://www.airportpristina.com/,https://en.wikipedia.org/wiki/Pristina_International_Airport,"LYPR, Pristina, Slatina Air Base"
2155,EBBR,large_airport,Brussels Airport,50.901402,4.48444,175,EU,BE,BE-VBR,Zaventem,yes,EBBR,BRU,EBBR,,http://www.brusselsairport.be/en/,https://en.wikipedia.org/wiki/Brussels_Airport,"Brussels National, Zaventem, EBMB"
2163,EBOS,large_airport,Ostend-Bruges International Airport,51.1998,2.874673,13,EU,BE,BE-VWV,Oostende,yes,EBOS,OST,EBOS,,http://www.ost.aero/,https://en.wikipedia.org/wiki/Ostend-Bruges_International_Airport,Ostend-Bruges International Airport
301881,EDDB,large_airport,Berlin Brandenburg Airport,52.361738,13.502341,157,EU,DE,DE-BR,Berlin,yes,EDDB,BER,EDDB,,https://ber.berlin-airport.de/,https://en.wikipedia.org/wiki/Berlin_Brandenburg_Airport,Willy Brandt
2210,EDDC,large_airport,Dresden Airport,51.134123,13.767831,755,EU,DE,DE-SN,Dresden,yes,EDDC,DRS,EDDC,,http://www.dresden-airport.de/en/index.html,https://en.wikipedia.org/wiki/Dresden_Airport,ETDN
2211,EDDE,large_airport,Erfurt-Weimar Airport,50.978281,10.960713,1036,EU,DE,DE-TH,Erfurt,yes,EDDE,ERF,EDDE,,http://www.airport-erfurt.de/,https://en.wikipedia.org/wiki/Erfurt_Airport,Flughafen Erfurt-Weimar
2212,EDDF,large_airport,Frankfurt Main Airport,50.026706,8.55835,364,EU,DE,DE-HE,Frankfurt am Main,yes,EDDF,FRA,EDDF,,https://www.frankfurt-airport.de/,https://en.wikipedia.org/wiki/Frankfurt_Airport,"EDAF, Frankfurt am Main, Frankfurt Main, Rhein-Main Air Base, Zeppelinheim"
2213,EDDG,large_airport,Münster Osnabrück Airport,52.133816,7.688482,160,EU,DE,DE-NW,Greven,yes,EDDG,FMO,EDDG,,https://www.fmo.de/,https://en.wikipedia.org/wiki/M%C3%BCnster_Osnabr%C3%BCck_International_Airport,
2214,EDDH,large_airport,Hamburg Helmut Schmidt Airport,53.630402,9.98823,53,EU,DE,DE-HH,Hamburg,yes,EDDH,HAM,EDDH,,https://www.hamburg-airport.de/en/,https://en.wikipedia.org/wiki/Hamburg_Airport,Hamburg-Fuhlsbüttel Airport
2216,EDDK,large_airport,Cologne Bonn Airport,50.865898,7.14274,302,EU,DE,DE-NW,Köln (Cologne),yes,EDDK,CGN,EDDK,,http://www.koeln-bonn-airport.de/en/,https://en.wikipedia.org/wiki/Cologne_Bonn_Airport,Köln
2217,EDDL,large_airport,Düsseldorf Airport,51.289501,6.76678,147,EU,DE,DE-NW,Düsseldorf,yes,EDDL,DUS,EDDL,,http://www.dus.com/dus_en/,https://en.wikipedia.org/wiki/D%C3%BCsseldorf_Airport,
2218,EDDM,large_airport,Munich Airport,48.353802,11.7861,1487,EU,DE,DE-BY,Munich,yes,EDDM,MUC,EDDM,,http://www.munich-airport.com/,https://en.wikipedia.org/wiki/Munich_Airport,"Franz Josef Strauss Airport, Flughafen München Franz Josef Strauß"
2219,EDDN,large_airport,Nuremberg Airport,49.498699,11.078056,1046,EU,DE,DE-BY,Nuremberg,yes,EDDN,NUE,EDDN,,https://www.airport-nuernberg.de/english,https://en.wikipedia.org/wiki/Nuremberg_Airport,Nürnberg
2220,EDDP,large_airport,Leipzig/Halle Airport,51.420657,12.232705,465,EU,DE,DE-SN,Schkeuditz,yes,EDDP,LEJ,EDDP,,https://www.mdf-ag.com/,https://en.wikipedia.org/wiki/Leipzig/Halle_Airport,Schkeuditz Airport
2222,EDDS,large_airport,Stuttgart Airport,48.689899,9.22196,1276,EU,DE,DE-BW,Stuttgart,yes,EDDS,STR,EDDS,,http://www.flughafen-stuttgart.de/,https://en.wikipedia.org/wiki/Stuttgart_Airport,
2224,EDDV,large_airport,Hannover Airport,52.461102,9.68508,183,EU,DE,DE-NI,Hannover,yes,EDDV,HAJ,EDDV,,https://www.hannover-airport.de,https://en.wikipedia.org/wiki/Hanover/Langenhagen_International_Airport,
2225,EDDW,large_airport,Bremen Airport,53.046786,8.78932,14,EU,DE,DE-HB,Bremen,yes,EDDW,BRE,EDDW,,https://www.bremen-airport.com,https://en.wikipedia.org/wiki/Bremen_Airport,
2227,EDFH,large_airport,Frankfurt-Hahn Airport,49.946353,7.261734,1649,EU,DE,DE-RP,Frankfurt am Main (Lautzenhausen),yes,EDFH,HHN,EDFH,,https://www.hahn-airport.de/en/home,https://en.wikipedia.org/wiki/Frankfurt-Hahn_Airport,
2237,EDJA,large_airport,Memmingen Allgau Airport,47.988092,10.238248,2077,EU,DE,DE-BY,Memmingen,yes,EDJA,FMM,EDJA,,,https://en.wikipedia.org/wiki/Memmingen_Airport,ETSM
2244,EDLP,large_airport,Paderborn Lippstadt Airport,51.612527,8.617459,699,EU,DE,DE-NW,Büren,yes,EDLP,PAD,EDLP,,,https://en.wikipedia.org/wiki/Paderborn_Lippstadt_Airport,
2247,EDLW,large_airport,Dortmund Airport,51.518299,7.61224,425,EU,DE,DE-NW,Dortmund,yes,EDLW,DTM,EDLW,,https://www.dortmund-airport.com/,https://en.wikipedia.org/wiki/Dortmund_Airport,
2256,EDNY,large_airport,Bodensee Airport Friedrichshafen,47.671299,9.51149,1367,EU,DE,DE-BW,Friedrichshafen,yes,EDNY,FDH,EDNY,,https://www.bodensee-airport.eu,https://en.wikipedia.org/wiki/Friedrichshafen_Airport,Bodensee Airport Friedrichshafen
2270,EDSB,large_airport,Karlsruhe Baden-Baden Airport,48.7794,8.0805,408,EU,DE,DE-BW,Rheinmünster,yes,EDSB,FKB,EDSB,,http://www.baden-airpark.de/startseite.html,https://en.wikipedia.org/wiki/Baden_Airpark,Baden Airpark
2285,EDVK,large_airport,Kassel Airport,51.418395,9.391638,820,EU,DE,DE-HE,Calden,yes,EDVK,KSF,EDVK,,http://www.flughafenkassel.de/,https://en.wikipedia.org/wiki/Kassel_Airport,Kassel-Calden
2301,EETN,large_airport,Lennart Meri Tallinn Airport,59.413246,24.83264,131,EU,EE,EE-37,Tallinn,yes,EETN,TLL,EETN,,https://airport.ee/,https://en.wikipedia.org/wiki/Tallinn_Airport,Tallinna Lennujaam
2307,EFHK,large_airport,Helsinki Vantaa Airport,60.318363,24.963341,179,EU,FI,FI-18,Helsinki (Vantaa),yes,EFHK,HEL,EFHK,,https://www.finavia.fi/en/airports/helsinki-airport,https://en.wikipedia.org/wiki/Helsinki_Airport,
2314,EFIV,large_airport,Ivalo Airport,68.6073,27.4053,481,EU,FI,FI-10,Ivalo,yes,EFIV,IVL,EFIV,,http://www.finavia.fi/en/ivalo/,https://en.wikipedia.org/wiki/Ivalo_Airport,
2325,EFKT,large_airport,Kittilä International Airport,67.700996,24.8468,644,EU,FI,FI-10,Kittilä,yes,EFKT,KTT,EFKT,,http://www.finavia.fi/en/kittila/,https://en.wikipedia.org/wiki/Kittil%C3%A4_Airport,
2326,EFKU,large_airport,Kuopio Airport,63.007099,27.7978,323,EU,FI,FI-15,Kuopio / Siilinjärvi,yes,EFKU,KUO,EFKU,,https://www.finavia.fi/en/kuopio/,https://en.wikipedia.org/wiki/Kuopio_Airport,
2329,EFLP,large_airport,Lappeenranta Airport,61.044601,28.144743,349,EU,FI,FI-02,Lappeenranta,yes,EFLP,LPP,EFLP,,https://lppairport.fi/en,https://en.wikipedia.org/wiki/Lappeenranta_Airport,
2334,EFOU,large_airport,Oulu Airport,64.930099,25.354601,47,EU,FI,FI-14,Oulu / Oulunsalo,yes,EFOU,OUL,EFOU,,https://www.finavia.fi/en/oulu/,https://en.wikipedia.org/wiki/Oulu_Airport,
2341,EFRO,large_airport,Rovaniemi Airport,66.563327,25.829751,642,EU,FI,FI-10,Rovaniemi,yes,EFRO,RVN,EFRO,,http://www.finavia.fi/en/rovaniemi/,https://en.wikipedia.org/wiki/Rovaniemi_Airport,
2347,EFTP,large_airport,Tampere-Pirkkala Airport,61.414101,23.604401,390,EU,FI,FI-11,Tampere / Pirkkala,yes,EFTP,TMP,EFTP,,https://www.finavia.fi/en/tampere-pirkkala/,https://en.wikipedia.org/wiki/Tampere-Pirkkala_Airport,
2349,EFTU,large_airport,Turku Airport,60.514099,22.2628,161,EU,FI,FI-19,Turku,yes,EFTU,TKU,EFTU,,https://www.finavia.fi/en/turku/,https://en.wikipedia.org/wiki/Turku_Airport,
2351,EFVA,large_airport,Vaasa Airport,63.05023,21.762543,19,EU,FI,FI-12,Vaasa,yes,EFVA,VAA,EFVA,,http://www.finavia.fi/en/vaasa/,https://en.wikipedia.org/wiki/Vaasa_Airport,
2385,EGAA,large_airport,Belfast International Airport,54.6575012207,-6.2158298492399995,268,EU,GB,GB-NIR,Belfast,yes,EGAA,BFS,EGAA,,http://www.belfastairport.com/,https://en.wikipedia.org/wiki/Belfast_International_Airport,"Aldergrove, RAF Aldergrove"
2389,EGBB,large_airport,Birmingham Airport,52.453899,-1.74803,327,EU,GB,GB-ENG,"Birmingham, West Midlands",yes,EGBB,BHX,EGBB,,https://www.birminghamairport.co.uk/,https://en.wikipedia.org/wiki/Birmingham_Airport,Birmingham International Airport
2398,EGCC,large_airport,Manchester Airport,53.349375,-2.279521,257,EU,GB,GB-ENG,"Manchester, Greater Manchester",yes,EGCC,MAN,EGCC,,http://www.manchesterairport.co.uk/,https://en.wikipedia.org/wiki/Manchester_Airport,"Ringway Airport, RAF Ringway"
2415,EGFF,large_airport,Cardiff International Airport,51.396702,-3.34333,220,EU,GB,GB-WLS,Cardiff,yes,EGFF,CWL,EGFF,,http://www.cwlfly.com/,https://en.wikipedia.org/wiki/Cardiff_Airport,"Caerdydd, Cardiff International Airport, Maes Awyr Caerdydd, Rhoose, Y Rhws"
2417,EGGD,large_airport,Bristol Airport,51.382326,-2.716453,622,EU,GB,GB-ENG,Bristol,yes,EGGD,BRS,EGGD,,http://www.bristolairport.co.uk/,https://en.wikipedia.org/wiki/Bristol_Airport,Lulsgate Bottom
2418,EGGP,large_airport,Liverpool John Lennon Airport,53.334863,-2.849637,80,EU,GB,GB-ENG,Liverpool,yes,EGGP,LPL,EGGP,,http://www.liverpoolairport.com/,https://en.wikipedia.org/wiki/Liverpool_John_Lennon_Airport,
2434,EGLL,large_airport,London Heathrow Airport,51.470748,-0.459909,83,EU,GB,GB-ENG,London,yes,EGLL,LHR,EGLL,,http://www.heathrow.com/,https://en.wikipedia.org/wiki/Heathrow_Airport,"LON, Londres"
2444,EGNM,large_airport,Leeds Bradford Airport,53.865898,-1.66057,681,EU,GB,GB-ENG,"Leeds, West Yorkshire",yes,EGNM,LBA,EGNM,,http://www.lbia.co.uk/,https://en.wikipedia.org/wiki/Leeds_Bradford_International_Airport,
2447,EGNS,large_airport,Isle of Man Airport,54.083081,-4.623871,52,EU,IM,IM-U-A,Castletown,yes,EGNS,IOM,EGNS,,http://www.iom-airport.com/,https://en.wikipedia.org/wiki/Isle_of_Man_Airport,"Ronaldsway, Douglas"
2448,EGNT,large_airport,Newcastle International Airport,55.037958,-1.689577,266,EU,GB,GB-ENG,"Newcastle upon Tyne, Tyne and Wear",yes,EGNT,NCL,EGNT,,http://www.newcastleairport.com/,https://en.wikipedia.org/wiki/Newcastle_International_Airport,
2450,EGNX,large_airport,East Midlands Airport,52.8311,-1.32806,306,EU,GB,GB-ENG,"Nottingham, Leicestershire",yes,EGNX,EMA,EGNX,,http://www.eastmidlandsairport.com/,https://en.wikipedia.org/wiki/East_Midlands_Airport,RAF Castle Donington
2459,EGPD,large_airport,Aberdeen International Airport,57.2019,-2.19778,215,EU,GB,GB-SCT,Aberdeen,yes,EGPD,ABZ,EGPD,,http://www.aberdeenairport.com/,https://en.wikipedia.org/wiki/Aberdeen_Airport,Dyce
2461,EGPF,large_airport,Glasgow Airport,55.871899,-4.43306,26,EU,GB,GB-SCT,Glasgow,yes,EGPF,GLA,EGPF,,http://www.glasgowairport.com/,https://en.wikipedia.org/wiki/Glasgow_International_Airport,"Port-adhair Eadar-nàiseanta Ghlaschu, RAF Abbotsinch, RAF Ayr"
2462,EGPH,large_airport,Edinburgh Airport,55.950145,-3.372288,135,EU,GB,GB-SCT,"Ingliston, Edinburgh",yes,EGPH,EDI,EGPH,,http://www.edinburghairport.com/,https://en.wikipedia.org/wiki/Edinburgh_Airport,
2513,EHAM,large_airport,Amsterdam Airport Schiphol,52.308601,4.76389,-11,EU,NL,NL-NH,Amsterdam,yes,EHAM,AMS,EHAM,,https://www.schiphol.nl/,https://en.wikipedia.org/wiki/Amsterdam_Airport_Schiphol,
2515,EHBK,large_airport,Maastricht Aachen Airport,50.911087,5.769401,375,EU,NL,NL-LI,Maastricht,yes,EHBK,MST,EHBK,,,https://en.wikipedia.org/wiki/Maastricht_Aachen_Airport,
2518,EHEH,large_airport,Eindhoven Airport,51.4500999451,5.37452983856,74,EU,NL,NL-NB,Eindhoven,yes,EHEH,EIN,EHEH,,http://www.eindhovenairport.nl/,https://en.wikipedia.org/wiki/Eindhoven_Airport,Welschap Airport
2519,EHGG,large_airport,Groningen Airport Eelde,53.119107,6.577652,17,EU,NL,NL-DR,Groningen,yes,EHGG,GRQ,EHGG,,,https://en.wikipedia.org/wiki/Groningen_Airport_Eelde,
2524,EHRD,large_airport,Rotterdam The Hague Airport,51.956902,4.43722,-15,EU,NL,NL-ZH,Rotterdam,yes,EHRD,RTM,EHRD,,https://www.rotterdamthehagueairport.nl/,https://en.wikipedia.org/wiki/Rotterdam_The_Hague_Airport,Vliegveld Zestienhoven
2530,EICK,large_airport,Cork International Airport,51.841301,-8.49111,502,EU,IE,IE-CO,Cork,yes,EICK,ORK,EICK,,http://www.corkairport.com/,https://en.wikipedia.org/wiki/Cork_Airport,Aerfort Chorcaí
2533,EIDW,large_airport,Dublin Airport,53.428713,-6.262121,242,EU,IE,IE-D,Dublin,yes,EIDW,DUB,EIDW,,http://www.dublinairport.com/,https://en.wikipedia.org/wiki/Dublin_Airport,Aerfort Bhaile Átha Cliath
2534,EIKN,large_airport,Ireland West Airport Knock,53.910439,-8.817002,665,EU,IE,IE-MO,Charlestown,yes,EIKN,NOC,EIKN,,http://www.irelandwestairport.com/,https://en.wikipedia.org/wiki/Ireland_West_Airport_Knock,"Connaught, Horan International Airport"
2537,EINN,large_airport,Shannon Airport,52.702,-8.92482,46,EU,IE,IE-CE,Shannon,yes,EINN,SNN,EINN,,http://www.shannonairport.com/,https://en.wikipedia.org/wiki/Shannon_Airport,
2540,EKAH,large_airport,Aarhus Airport,56.303331,10.618286,82,EU,DK,DK-82,Aarhus,yes,EKAH,AAR,EKAH,,http://www.aar.dk/,https://en.wikipedia.org/wiki/Aarhus_Airport,Tirstrup
2541,EKBI,large_airport,Billund Airport,55.740335,9.157019,247,EU,DK,DK-83,Billund,yes,EKBI,BLL,EKBI,,http://www.billund-airport.dk/?sc_lang=en,https://en.wikipedia.org/wiki/Billund_Airport,
2542,EKCH,large_airport,Copenhagen Kastrup Airport,55.617900848389,12.656000137329,17,EU,DK,DK-84,Copenhagen,yes,EKCH,CPH,EKCH,,http://www.cph.dk/en/,https://en.wikipedia.org/wiki/Copenhagen_Airport,"København, Malmö"
2549,EKOD,large_airport,Odense Hans Christian Andersen Airport,55.475307,10.327206,56,EU,DK,DK-83,Odense,yes,EKOD,ODE,EKOD,,,https://en.wikipedia.org/wiki/Hans_Christian_Andersen_Airport,Beldringe
2559,EKVG,large_airport,Vágar Airport,62.063256,-7.275782,280,EU,FO,FO-U-A,Vágar,yes,EKVG,FAE,EKVG,,https://www.fae.fo/,https://en.wikipedia.org/wiki/V%C3%A1gar_Airport,"Faroes, RAF Vágar"
2562,EKYT,large_airport,Aalborg Airport,57.094763,9.84993,10,EU,DK,DK-81,Aalborg,yes,EKYT,AAL,EKYT,,http://www.aal.dk,https://en.wikipedia.org/wiki/Aalborg_Airport,
2563,ELLX,large_airport,Luxembourg-Findel International Airport,49.626845,6.212134,1234,EU,LU,LU-L,Luxembourg,yes,ELLX,LUX,ELLX,,https://www.lux-airport.lu/en/,https://en.wikipedia.org/wiki/Luxembourg-Findel_International_Airport,
2564,ENAL,large_airport,Ålesund Airport,62.560443,6.110845,69,EU,NO,NO-15,Ålesund,yes,ENAL,AES,ENAL,,https://www.avinor.no/airport/alesund/,https://en.wikipedia.org/wiki/%C3%85lesund_Airport%2C_Vigra,"Vigra, Ålesund Vigra Airport"
2569,ENBO,large_airport,Bodø Airport,67.269203,14.3653,42,EU,NO,NO-18,Bodø,yes,ENBO,BOO,ENBO,,http://www.avinor.no/en/airport/bodo/,https://en.wikipedia.org/wiki/Bod%C3%B8_Airport,"Bodø Air Force Base, Bodø Main Air Station"
2570,ENBR,large_airport,"Bergen Airport, Flesland",60.2934,5.21814,170,EU,NO,NO-46,Bergen,yes,ENBR,BGO,ENBR,,http://www.avinor.no/en/airport/bergen,https://en.wikipedia.org/wiki/Bergen_Airport%2C_Flesland,
2572,ENCN,large_airport,Kristiansand Airport,58.204201,8.08537,57,EU,NO,NO-42,Kristiansand(Kjevik),yes,ENCN,KRS,ENCN,,http://www.avinor.no/en/airport/kristiansand,https://en.wikipedia.org/wiki/Kristiansand_Airport%2C_Kjevik,Kjevik Airport
2575,ENEV,large_airport,Harstad/Narvik Airport,68.491302,16.678101,84,EU,NO,NO-18,Evenes,yes,ENEV,EVE,ENEV,,https://avinor.no/en/airport/harstadnarvik-airport/,https://en.wikipedia.org/wiki/Harstad/Narvik_Airport%2C_Evenes,"Harstad/Narvik lufthavn, Evenes, Evenes Airport"
2578,ENGM,large_airport,Oslo-Gardermoen International Airport,60.193901,11.1004,681,EU,NO,NO-32,Oslo (Gardermoen),yes,ENGM,OSL,ENGM,,https://avinor.no/en/airport/oslo-airport,"https://en.wikipedia.org/wiki/Oslo_Airport,_Gardermoen",
2599,ENTC,large_airport,Tromsø Airport,69.683296,18.9189,31,EU,NO,NO-55,Tromsø,yes,ENTC,TOS,ENTC,,http://www.avinor.no/en/airport/tromso,https://en.wikipedia.org/wiki/Troms%C3%B8_Airport,"Langnes, Tromsøya, Tromso"
2600,ENTO,large_airport,"Sandefjord Airport, Torp",59.186699,10.2586,286,EU,NO,NO-39,Sandefjord(Torp),yes,ENTO,TRF,ENTO,,https://torp.no/,https://en.wikipedia.org/wiki/Sandefjord_Airport%2C_Torp,Torp
2601,ENVA,large_airport,"Trondheim Airport, Værnes",63.457802,10.924,56,EU,NO,NO-50,Trondheim,yes,ENVA,TRD,ENVA,,https://avinor.no/en/airport/trondheim-airport/,https://en.wikipedia.org/wiki/Trondheim_Airport%2C_V%C3%A6rnes,
2602,ENZV,large_airport,"Stavanger Airport, Sola",58.876701,5.63778,29,EU,NO,NO-11,Stavanger,yes,ENZV,SVG,ENZV,,http://www.avinor.no/en/airport/stavanger/,"https://en.wikipedia.org/wiki/Stavanger_Airport,_Sola","Sola Air Station, Stavanger lufthavn, Sola"
2608,EPGD,large_airport,Gdańsk Lech Wałęsa Airport,54.377602,18.4662,489,EU,PL,PL-PM,Gdańsk,yes,EPGD,GDN,EPGD,,http://www.airport.gdansk.pl/,https://en.wikipedia.org/wiki/Gda%C5%84sk_Lech_Wa%C5%82%C4%99sa_Airport,Gdańsk-Rębiechowo
2609,EPKK,large_airport,Kraków John Paul II International Airport,50.077702,19.7848,791,EU,PL,PL-MA,Balice,yes,EPKK,KRK,EPKK,,http://www.krakowairport.pl/en/,https://en.wikipedia.org/wiki/Kraków_John_Paul_II_International_Airport,Krakov
2613,EPKT,large_airport,Katowice Wojciech Korfanty International Airport,50.476015,19.080705,995,EU,PL,PL-SL,Katowice,yes,EPKT,KTW,EPKT,,http://katowice.airport.com,https://en.wikipedia.org/wiki/Katowice_International_Airport,"Pyrzowice, Dąbrowa Górnicza, Katowice, Silesia, GOP, Silesia Metropolis, Gliwice, Piekary Śląskie, Bytom, Zabrze, Sosnowiec"
309484,EPLB,large_airport,Lublin Airport,51.240157,22.713461,633,EU,PL,PL-LU,Lublin,yes,EPLB,LUZ,EPLB,,,https://en.wikipedia.org/wiki/Lublin_Airport,
2616,EPLL,large_airport,Łódź Władysław Reymont Airport,51.721901,19.3981,604,EU,PL,PL-LD,Łódź,yes,EPLL,LCJ,EPLL,,https://www.lodz-airport.pl/en,https://en.wikipedia.org/wiki/%C5%81%C3%B3d%C5%BA_W%C5%82adys%C5%82aw_Reymont_Airport,Lublinek
2625,EPPO,large_airport,Poznań-Ławica Airport,52.421598,16.823359,308,EU,PL,PL-WP,Poznań,yes,EPPO,POZ,EPPO,,https://poznanairport.pl,https://en.wikipedia.org/wiki/Pozna%C5%84-%C5%81awica_Airport,Poznań–Ławica Henryk Wieniawski Airport
2629,EPRZ,large_airport,Rzeszów-Jasionka Airport,50.109791,22.024155,693,EU,PL,PL-PK,Jasionka,yes,EPRZ,RZE,EPRZ,,https://www.rzeszowairport.pl,https://en.wikipedia.org/wiki/Rzesz%C3%B3w-Jasionka_Airport,
2630,EPSC,large_airport,Solidarity Szczecin–Goleniów Airport,53.584702,14.9022,154,EU,PL,PL-ZP,Szczecin(Glewice),yes,EPSC,SZZ,EPSC,,http://www.airport.com.pl/en/index.php,https://en.wikipedia.org/wiki/Szczecin-Goleni%C3%B3w_%22Solidarno%C5%9B%C4%87%22_Airport,"Solidarność, Glewice, Port Lotniczy Szczecin–Goleniów im. NSZZ Solidarność"
2637,EPWA,large_airport,Warsaw Chopin Airport,52.165699,20.9671,362,EU,PL,PL-MZ,Warsaw,yes,EPWA,WAW,EPWA,,http://www.lotnisko-chopina.pl/?lang=en,https://en.wikipedia.org/wiki/Warsaw_Frederic_Chopin_Airport,"Okęcie, Warszawa"
2638,EPWR,large_airport,Copernicus Wrocław Airport,51.103719,16.882096,404,EU,PL,PL-DS,Wrocław,yes,EPWR,WRO,EPWR,,http://www.airport.wroclaw.pl/en/index.php,https://en.wikipedia.org/wiki/Copernicus_Airport_Wroc%C5%82aw,"Strachowice, Breslau, Wroclaw"
2648,ESGG,large_airport,Göteborg Landvetter Airport,57.6628,12.2798,506,EU,SE,SE-Q,Göteborg,yes,ESGG,GOT,ESGG,,https://www.swedavia.com/landvetter/,https://en.wikipedia.org/wiki/G%C3%B6teborg_Landvetter_Airport,"Göteborg-Landvetter, Gothenburg"
2660,ESKN,large_airport,Stockholm Skavsta Airport,58.789656,16.911479,140,EU,SE,SE-D,Nyköping,yes,ESKN,NYO,ESKN,,,https://en.wikipedia.org/wiki/Stockholm-Skavsta_Airport,
316498,ESKS,large_airport,Scandinavian Mountains Airport,61.16508,12.83352,1608,EU,SE,SE-W,Malung-Sälen,yes,ESKS,SCR,ESKS,,https://scandinavianmountains.se/en/home/,https://en.wikipedia.org/wiki/Scandinavian_Mountains_Airport,"Sälens Flygplats, Trysil, Sälen Trysil Airport, Sälen/Scandinavian Mountains Airport"
2669,ESMS,large_airport,Malmö Sturup Airport,55.535564,13.376327,236,EU,SE,SE-M,Malmö,yes,ESMS,MMX,ESMS,,http://www.swedavia.com/malmo/,https://en.wikipedia.org/wiki/Malm%C3%B6_Airport,
2685,ESNQ,large_airport,Kiruna Airport,67.821999,20.3368,1508,EU,SE,SE-BD,Kiruna,yes,ESNQ,KRN,ESNQ,,https://www.swedavia.com/kiruna/,https://en.wikipedia.org/wiki/Kiruna_Airport,
2689,ESNU,large_airport,Umeå Airport,63.791801,20.282801,24,EU,SE,SE-AC,Umeå,yes,ESNU,UME,ESNU,,https://www.swedavia.com/umea/,https://en.wikipedia.org/wiki/Ume%C3%A5_Airport,
2696,ESOW,large_airport,Stockholm Västerås Airport,59.589401,16.6336,21,EU,SE,SE-U,Stockholm / Västerås,yes,ESOW,VST,ESOW,,,https://en.wikipedia.org/wiki/Stockholm-V%C3%A4ster%C3%A5s_Airport,
2697,ESPA,large_airport,Luleå Airport,65.5438,22.122,65,EU,SE,SE-BD,Luleå,yes,ESPA,LLA,ESPA,,https://www.swedavia.com/lulea/,https://en.wikipedia.org/wiki/Lule%C3%A5_Airport,Kallax Air Base
2701,ESSA,large_airport,Stockholm-Arlanda Airport,59.64849,17.928829,137,EU,SE,SE-AB,Stockholm,yes,ESSA,ARN,ESSA,,http://www.swedavia.se/arlanda/,https://en.wikipedia.org/wiki/Stockholm-Arlanda_Airport,
2706,ESSL,large_airport,Linköping City Airport,58.404909,15.684499,172,EU,SE,SE-E,Linköping,yes,ESSL,LPI,ESSL,,http://www.linkopingsflygplats.se/,https://en.wikipedia.org/wiki/Link%C3%B6ping_SAAB_Airport,
2710,ESSV,large_airport,Visby Airport,57.6628,18.346201,164,EU,SE,SE-I,Visby,yes,ESSV,VBY,ESSV,,https://www.swedavia.com/visby/,https://en.wikipedia.org/wiki/Visby_Airport,
2757,EVLA,large_airport,Liepāja International Airport,56.517502,21.096901,16,EU,LV,LV-112,Liepāja,no,EVLA,LPX,EVLA,,http://www.liepaja-airport.lv/,https://en.wikipedia.org/wiki/Liep%C4%81ja_International_Airport,
2758,EVRA,large_airport,Riga International Airport,56.920752,23.970711,36,EU,LV,LV-062,Riga,yes,EVRA,RIX,EVRA,,http://www.riga-airport.com/,https://en.wikipedia.org/wiki/Riga_International_Airport,
2760,EYKA,large_airport,Kaunas International Airport,54.963965,24.08582,256,EU,LT,LT-KU,Kaunas,yes,EYKA,KUN,EYKA,,http://www.kaunasair.lt/index.php?lang=2&m=2&p=210,https://en.wikipedia.org/wiki/Kaunas_Airport,Kauno tarptautinis Oro Uostas
2762,EYPA,large_airport,Palanga International Airport,55.973202,21.093901,33,EU,LT,LT-KL,Palanga,yes,EYPA,PLQ,EYPA,,http://www.palanga-airport.lt/,https://en.wikipedia.org/wiki/Palanga_International_Airport,
2766,EYVI,large_airport,Vilnius International Airport,54.634102,25.285801,648,EU,LT,LT-VL,Vilnius,yes,EYVI,VNO,EYVI,,http://www.vilnius-airport.lt/en/,https://en.wikipedia.org/wiki/Vilnius_International_Airport,
3972,LATI,large_airport,Tirana International Airport Mother Teresa,41.4147,19.7206,126,EU,AL,AL-02,Rinas,yes,LATI,TIA,LATI,,http://www.tirana-airport.com/,https://en.wikipedia.org/wiki/Tirana_International_Airport_N%C3%ABn%C3%AB_Tereza,"Rinas International Airport, Aeroporti Nënë Tereza"
3974,LBBG,large_airport,Burgas Airport,42.569917,27.515173,135,EU,BG,BG-02,Burgas,yes,LBBG,BOJ,LBBG,,,https://en.wikipedia.org/wiki/Burgas_Airport,Bourgas
3976,LBPD,large_airport,Plovdiv International Airport,42.067799,24.8508,597,EU,BG,BG-16,Plovdiv,yes,LBPD,PDV,LBPD,,http://www.plovdivairport.com/,https://en.wikipedia.org/wiki/Plovdiv_International_Airport,
3977,LBSF,large_airport,Sofia Airport,42.696357,23.417671,1742,EU,BG,BG-22,Sofia,yes,LBSF,SOF,LBSF,,http://sofia-airport.bg,https://en.wikipedia.org/wiki/Sofia_Airport,"Sofia International Airport, Vrazhdebna Air Base"
3979,LBWN,large_airport,Varna Airport,43.232101,27.8251,230,EU,BG,BG-03,Varna,yes,LBWN,VAR,LBWN,,,https://en.wikipedia.org/wiki/Varna_Airport,Aksakovo
3984,LDDU,large_airport,Dubrovnik Ruđer Bošković Airport,42.562247,18.265543,527,EU,HR,HR-19,Dubrovnik,yes,LDDU,DBV,LDDU,,https://www.airport-dubrovnik.hr/en,https://en.wikipedia.org/wiki/Dubrovnik_Airport,"Močići Airport, Zračna luka Ruđer Bošković Dubrovnik"
3987,LDPL,large_airport,Pula Airport,44.893501,13.9222,274,EU,HR,HR-18,Pula,yes,LDPL,PUY,LDPL,,https://airport-pula.hr/en/,https://en.wikipedia.org/wiki/Pula_Airport,
3989,LDRI,large_airport,Rijeka Airport,45.216376,14.57085,278,EU,HR,HR-08,Rijeka(Omišalj),yes,LDRI,RJK,LDRI,,https://www.rijeka-airport.hr/,https://en.wikipedia.org/wiki/Rijeka_Airport,"Reka, Rika, Fiume, Omišalj"
3991,LDSP,large_airport,Split Saint Jerome Airport,43.538898,16.298,79,EU,HR,HR-17,Split,yes,LDSP,SPU,LDSP,,https://split-airport.hr,https://en.wikipedia.org/wiki/Split_Airport,
3993,LDZA,large_airport,Zagreb Franjo Tuđman International Airport,45.742901,16.0688,353,EU,HR,HR-01,Velika Gorica,yes,LDZA,ZAG,LDZA,,https://www.zagreb-airport.hr/en,https://en.wikipedia.org/wiki/Zagreb_Airport,Pleso
3994,LDZD,large_airport,Zadar Airport,44.096986,15.353565,289,EU,HR,HR-13,Zadar,yes,LDZD,ZAD,LDZD,,https://www.zadar-airport.hr/en,https://en.wikipedia.org/wiki/Zadar_Airport,
3997,LEAL,large_airport,Alicante-Elche Miguel Hernández Airport,38.2822,-0.558156,142,EU,ES,ES-VC,Alicante,yes,LEAL,ALC,LEAL,,https://www.aena.es/en/alicante-elche.html,https://en.wikipedia.org/wiki/Alicante–Elche_Miguel_Hernández_Airport,
3999,LEAS,large_airport,Asturias Airport,43.563599,-6.03462,416,EU,ES,ES-AS,Ranón,yes,LEAS,OVD,LEAS,,https://www.aena.es/en/asturias.html,https://en.wikipedia.org/wiki/Asturias_Airport,
4001,LEBB,large_airport,Bilbao Airport,43.301102,-2.91061,138,EU,ES,ES-PV,Bilbao,yes,LEBB,BIO,LEBB,,https://www.aena.es/en/bilbao.html,https://en.wikipedia.org/wiki/Bilbao_Airport,
4004,LEBL,large_airport,Josep Tarradellas Barcelona-El Prat Airport,41.2971,2.07846,12,EU,ES,ES-CT,Barcelona,yes,LEBL,BCN,LEBL,,https://www.aena.es/en/josep-tarradellas-barcelona-el-prat.html,https://en.wikipedia.org/wiki/Barcelona%E2%80%93El_Prat_Airport,"El Prat Airport,Barcelona International,Josep Tarradellas Barcelona-El Prat Airport"
4010,LEGE,large_airport,Girona-Costa Brava Airport,41.904639,2.761774,468,EU,ES,ES-CT,Girona,yes,LEGE,GRO,LEGE,,https://www.aena.es/en/girona-costa-brava.html,https://en.wikipedia.org/wiki/Girona-Costa_Brava_Airport,"Girona, Costa Brava, Girona-Costa Brava"
4013,LEIB,large_airport,Ibiza Airport,38.872898,1.37312,24,EU,ES,ES-IB,Ibiza (Eivissa),yes,LEIB,IBZ,LEIB,,https://www.aena.es/en/ibiza.html,https://en.wikipedia.org/wiki/Ibiza_Airport,
4019,LEMD,large_airport,Adolfo Suárez Madrid–Barajas Airport,40.493407,-3.572249,1998,EU,ES,ES-MD,Madrid,yes,LEMD,MAD,LEMD,,https://www.aena.es/en/adolfo-suarez-madrid-barajas.html,https://en.wikipedia.org/wiki/Adolfo_Su%C3%A1rez_Madrid%E2%80%93Barajas_Airport,"Leganés, Madrid Barajas International Airport"
4020,LEMG,large_airport,Málaga-Costa del Sol Airport,36.6749,-4.49911,53,EU,ES,ES-AN,Málaga,yes,LEMG,AGP,LEMG,,https://www.aena.es/en/malaga-costa-del-sol.html,https://en.wikipedia.org/wiki/M%C3%A1laga_Airport,"Costa del Sol, Malaga"
4021,LEMH,large_airport,Menorca Airport,39.862598,4.21865,302,EU,ES,ES-IB,Mahón (Maó),yes,LEMH,MAH,LEMH,,https://www.aena.es/en/menorca.html,https://en.wikipedia.org/wiki/Menorca_Airport,Menorca
308134,LEMI,large_airport,Region of Murcia International Airport,37.802852,-1.124884,644,EU,ES,ES-MC,Corvera,yes,LEMI,RMU,LEMI,,https://www.aena.es/en/internacional-region-de-murcia.html,https://en.wikipedia.org/wiki/Murcia-Corvera_Airport,
4035,LEPA,large_airport,Palma de Mallorca Airport,39.551701,2.73881,27,EU,ES,ES-IB,Palma de Mallorca,yes,LEPA,PMI,LEPA,,https://www.aena.es/en/palma-de-mallorca.html,https://en.wikipedia.org/wiki/Son_Sant_Joan_Airport,"Son Sant Joan Airport, LESJ"
4031,LERS,large_airport,Reus Airport,41.147509,1.168354,233,EU,ES,ES-CT,Reus,yes,LERS,REU,LERS,,https://www.aena.es/en/reus.html,https://en.wikipedia.org/wiki/Reus_Airport,
4038,LEST,large_airport,Santiago-Rosalía de Castro Airport,42.896301,-8.41514,1213,EU,ES,ES-GA,Santiago de Compostela,yes,LEST,SCQ,LEST,,https://www.aena.es/en/santiago-rosalia-de-castro.html,https://en.wikipedia.org/wiki/Santiago_de_Compostela_Airport,
4042,LEVC,large_airport,Valencia Airport,39.489162,-0.480961,240,EU,ES,ES-VC,Valencia,yes,LEVC,VLC,LEVC,,https://www.aena.es/en/valencia.html,https://en.wikipedia.org/wiki/Valencia_Airport,
4048,LEZG,large_airport,Zaragoza Airport,41.666199,-1.04155,863,EU,ES,ES-AR,Zaragoza,yes,LEZG,ZAZ,LEZG,,https://www.aena.es/en/zaragoza.html,https://en.wikipedia.org/wiki/Zaragoza_Airport,Zaragoza Air Base
4049,LEZL,large_airport,Seville Airport,37.417999,-5.89311,112,EU,ES,ES-AN,Seville,yes,LEZL,SVQ,LEZL,,https://www.aena.es/en/seville.html,https://en.wikipedia.org/wiki/Seville_Airport,"Sevilla, Sevilla Airport"
4060,LFBD,large_airport,Bordeaux–Mérignac Airport,44.82865,-0.715356,162,EU,FR,FR-NAQ,Bordeaux,yes,LFBD,BOD,LFBD,,https://www.bordeaux.aeroport.fr/,https://en.wikipedia.org/wiki/Bordeaux%E2%80%93M%C3%A9rignac_Airport,
4070,LFBO,large_airport,Toulouse-Blagnac Airport,43.629101,1.36382,499,EU,FR,FR-OCC,Toulouse/Blagnac,yes,LFBO,TLS,LFBO,,http://www.toulouse.aeroport.fr/,https://en.wikipedia.org/wiki/Toulouse_Blagnac_International_Airport,Airbus
4123,LFKB,large_airport,Bastia-Poretta International airport,42.5527,9.48373,26,EU,FR,FR-COR,Bastia,yes,LFKB,BIA,LFKB,,https://bastia-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Bastia_-_Poretta_Airport,
4125,LFKF,large_airport,Figari Sud-Corse Airport,41.50185,9.097092,85,EU,FR,FR-COR,Figari,yes,LFKF,FSC,LFKF,,https://figari-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Figari_Sud-Corse_Airport,
4132,LFLC,large_airport,Clermont-Ferrand Auvergne airport,45.786701,3.16917,1090,EU,FR,FR-ARA,Clermont-Ferrand,yes,LFLC,CFE,LFLC,,https://www.clermont-aeroport.com/en,https://en.wikipedia.org/wiki/Clermont-Ferrand_Auvergne_Airport,
4137,LFLL,large_airport,Lyon Saint-Exupéry Airport,45.725996,5.090139,821,EU,FR,FR-ARA,"Colombier-Saugnieu, Rhône",yes,LFLL,LYS,LFLL,,https://www.lyonaeroports.com/,https://en.wikipedia.org/wiki/Lyon%E2%80%93Saint-Exup%C3%A9ry_Airport,"Lyon, Saint-Exupéry, Satolas"
4155,LFML,large_airport,Marseille Provence Airport,43.438088,5.2125,74,EU,FR,FR-PAC,"Marignane, Bouches-du-Rhône",yes,LFML,MRS,LFML,,https://www.marseille.aeroport.fr/,https://en.wikipedia.org/wiki/Marseille_Provence_Airport,
4156,LFMN,large_airport,Nice-Côte d'Azur Airport,43.658401,7.21587,12,EU,FR,FR-PAC,"Nice, Alpes-Maritimes",yes,LFMN,NCE,LFMN,,https://www.nice.aeroport.fr/en,https://en.wikipedia.org/wiki/C%C3%B4te_d'Azur_International_Airport,
4161,LFMT,large_airport,Montpellier-Méditerranée Airport,43.576199,3.96301,17,EU,FR,FR-OCC,Montpellier/Méditerranée,yes,LFMT,MPL,LFMT,,https://www.montpellier.aeroport.fr/,https://en.wikipedia.org/wiki/Montpellier_-_M%C3%A9diterran%C3%A9e_Airport,
4169,LFOB,large_airport,Beauvais-Tillé airport,49.454399,2.11278,359,EU,FR,FR-HDF,Beauvais,yes,LFOB,BVA,LFOB,,https://www.aeroportparisbeauvais.com/,https://en.wikipedia.org/wiki/Paris_Beauvais_Till%C3%A9_Airport,Beauvais-Tillé
4185,LFPG,large_airport,Charles de Gaulle International Airport,49.00896,2.554117,392,EU,FR,FR-IDF,"Paris (Roissy-en-France, Val-d'Oise)",yes,LFPG,CDG,LFPG,,http://www.aeroportsdeparis.fr/,https://en.wikipedia.org/wiki/Charles_de_Gaulle_Airport,"PAR, Aéroport Roissy-Charles de Gaulle, Roissy Airport"
4202,LFQQ,large_airport,Lille Airport,50.566564,3.102429,157,EU,FR,FR-HDF,Lesquin,yes,LFQQ,LIL,LFQQ,,https://www.lille.aeroport.fr/home/,https://en.wikipedia.org/wiki/Lille_Airport,Aéroport de Lille. Lille-Lesquin Airport
4206,LFRB,large_airport,Brest Bretagne airport,48.447899,-4.41854,325,EU,FR,FR-BRE,Brest,yes,LFRB,BES,LFRB,,https://www.brest.aeroport.bzh,https://en.wikipedia.org/wiki/Brest_Bretagne_Airport,
4221,LFRS,large_airport,Nantes Atlantique Airport,47.153198,-1.61073,90,EU,FR,FR-PDL,Nantes,yes,LFRS,NTE,LFRS,,https://www.nantes.aeroport.fr/en,https://en.wikipedia.org/wiki/Nantes_Atlantique_Airport,
4226,LFSB,large_airport,EuroAirport Basel–Mulhouse–Freiburg,47.60068,7.521117,885,EU,FR,FR-GES,Bâle / Mulhouse,yes,LFSB,BSL,LFSB,,http://www.euroairport.com/,https://en.wikipedia.org/wiki/EuroAirport_Basel-Mulhouse-Freiburg,"MLH, EAP, LSZM, Bâle, Basle"
4238,LFST,large_airport,Strasbourg Airport,48.5383,7.62823,505,EU,FR,FR-GES,Strasbourg,yes,LFST,SXB,LFST,,http://www.strasbourg.aeroport.fr/E/index.php,https://en.wikipedia.org/wiki/Strasbourg_Airport,BA 124
4251,LGAV,large_airport,Athens Eleftherios Venizelos International Airport,37.936401,23.9445,308,EU,GR,GR-I,Spata-Artemida,yes,LGAV,ATH,LGAV,,https://www.aia.gr,https://en.wikipedia.org/wiki/Athens_International_Airport,
4258,LGIR,large_airport,Heraklion International Nikos Kazantzakis Airport,35.339699,25.1803,115,EU,GR,GR-M,Heraklion,yes,LGIR,HER,LGIR,,https://www.heraklion-airport.gr/en/,"https://en.wikipedia.org/wiki/Heraklion_International_Airport_""Nikos_Kazantzakis""",Crete Island
4266,LGKR,large_airport,Corfu Ioannis Kapodistrias International Airport,39.60145,19.912179,6,EU,GR,GR-F,Kerkyra (Corfu),yes,LGKR,CFU,LGKR,,https://www.cfu-airport.gr/en,https://en.wikipedia.org/wiki/Corfu_International_Airport,Corfu
4268,LGKV,large_airport,Kavala Alexander the Great International Airport,40.9133,24.6192,18,EU,GR,GR-A,Kavala,yes,LGKV,KVA,LGKV,,https://www.kva-airport.gr/en,https://en.wikipedia.org/wiki/Kavala_International_Airport,Megas Alexandros
4281,LGSA,large_airport,Chania International Airport,35.531207,24.150673,490,EU,GR,GR-M,Souda,yes,LGSA,CHQ,LGSA,,,https://en.wikipedia.org/wiki/Chania_International_Airport,
4286,LGSR,large_airport,Santorini International Airport,36.400045,25.478638,127,EU,GR,GR-L,Santorini Island,yes,LGSR,JTR,LGSR,,https://www.jtr-airport.gr/,https://en.wikipedia.org/wiki/Santorini_(Thira)_National_Airport,
4293,LGTS,large_airport,Thessaloniki Macedonia International Airport,40.51928,22.970009,22,EU,GR,GR-B,Thessaloniki,yes,LGTS,SKG,LGTS,,https://www.skg-airport.gr/en,https://en.wikipedia.org/wiki/Thessaloniki_Airport,Makedonia. Macedonia. Salonica
4296,LHBP,large_airport,Budapest Liszt Ferenc International Airport,47.43018,19.262393,495,EU,HU,HU-BU,Budapest,yes,LHBP,BUD,LHBP,,http://www.bud.hu/english,https://en.wikipedia.org/wiki/Budapest_Ferenc_Liszt_International_Airport,"Ferihegyi nemzetközi repülőtér, Budapest Liszt Ferenc international Airport"
4297,LHDC,large_airport,Debrecen International Airport,47.489469,21.616278,359,EU,HU,HU-HB,Debrecen,yes,LHDC,DEB,LHDC,,http://www.airportdebrecen.hu/,https://en.wikipedia.org/wiki/Debrecen_International_Airport,Debreceni nemzetközi repülőtér
4302,LHPP,large_airport,Pécs-Pogány International Airport,45.988891,18.242044,651,EU,HU,HU-BA,Pécs,yes,LHPP,PEV,LHPP,,http://www.airportpecs.hu/,https://en.wikipedia.org/wiki/P%C3%A9cs-Pog%C3%A1ny_International_Airport,"Pecs South Airport, QPJ"
4310,LIBD,large_airport,Bari Karol Wojtyła International Airport,41.138901,16.760599,193,EU,IT,IT-75,Bari,yes,LIBD,BRI,LIBD,BA02,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=25,https://en.wikipedia.org/wiki/Bari_Karol_Wojty%C5%82a_Airport,"Bari ""Karol Wojtyla"" International Airport, Palese Macchie Airport, Palese Airport"
4314,LIBP,large_airport,Abruzzo Airport,42.431079,14.182981,48,EU,IT,IT-65,Pescara,yes,LIBP,PSR,LIBP,PE01,http://www.abruzzo-airport.it/eng/index.php,https://en.wikipedia.org/wiki/Abruzzo_Airport,"Pescara, Abruzzo Pasquale Liberi International"
4315,LIBR,large_airport,Brindisi Airport,40.6576,17.947001,47,EU,IT,IT-75,Brindisi,yes,LIBR,BDS,LIBR,BR03,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=29,https://en.wikipedia.org/wiki/Brindisi_Airport,"Brindisi Papola Casale Airport, Brindisi Salento Airport, Brindisi Casale Antonio Papolo Airport, Salento Airport"
4317,LICA,large_airport,Lamezia Terme Sant'Eufemia International Airport,38.906214,16.246007,39,EU,IT,IT-78,Lamezia Terme (CZ),yes,LICA,SUF,LICA,CZ02,,https://en.wikipedia.org/wiki/Lamezia_Terme_International_Airport,
4318,LICC,large_airport,Catania-Fontanarossa Airport,37.466801,15.0664,39,EU,IT,IT-82,Catania,yes,LICC,CTA,LICC,CT03,http://www.aeroporto.catania.it/,https://en.wikipedia.org/wiki/Catania-Fontanarossa_Airport,"Lanza Di Trabie, Catania-Fontanarossa Vincenzo Bellini"
4321,LICJ,large_airport,Falcone–Borsellino Airport,38.175999,13.091,65,EU,IT,IT-82,Palermo,yes,LICJ,PMO,LICJ,PA03,http://www.gesap.it/index.php,https://en.wikipedia.org/wiki/Falcone%E2%80%93Borsellino_Airport,"Palermo Airport, Punta Raisi Airport"
4332,LIEE,large_airport,Cagliari Elmas Airport,39.251499,9.05428,13,EU,IT,IT-88,Cagliari,yes,LIEE,CAG,LIEE,CA07,http://www.cagliariairport.it/en/,https://en.wikipedia.org/wiki/Cagliari_Elmas_Airport,"Cagliari Airport,Elmas"
4333,LIEO,large_airport,Olbia Costa Smeralda Airport,40.898953,9.518457,37,EU,IT,IT-88,Olbia (SS),yes,LIEO,OLB,LIEO,SS03,https://www.geasar.it/en,https://en.wikipedia.org/wiki/Olbia_Costa_Smeralda_Airport,
4340,LIMC,large_airport,Milan Malpensa International Airport,45.6306,8.72811,768,EU,IT,IT-25,Ferno (VA),yes,LIMC,MXP,LIMC,MI12,http://www.sea-aeroportimilano.it/en/malpensa/index.phtml,https://en.wikipedia.org/wiki/Malpensa_Airport,"MIL, Silvio Berlusconi"
4342,LIMF,large_airport,Turin Airport,45.200802,7.64963,989,EU,IT,IT-21,Caselle Torinese (TO),yes,LIMF,TRN,LIMF,TO11,http://www.aeroportoditorino.it/,https://en.wikipedia.org/wiki/Turin_International_Airport,"Torino-Caselle Airport, Sandro Pertini Airport, Turin-Caselle Airport"
4344,LIMJ,large_airport,Genoa Cristoforo Colombo Airport,44.412039,8.840732,13,EU,IT,IT-42,Genova (GE),yes,LIMJ,GOA,LIMJ,GE01,http://www.aeroportodigenova.com/en/home/index.php,https://en.wikipedia.org/wiki/Genoa_Cristoforo_Colombo_Airport,"Genova Sestri, Sestri Cristoforo Colombo Airport, Cristoforo Colombo Airport"
4354,LIPE,large_airport,Bologna Guglielmo Marconi Airport,44.5354,11.2887,123,EU,IT,IT-45,Bologna,yes,LIPE,BLQ,LIPE,BO08,http://www.bologna-airport.it/?LN=UK,https://en.wikipedia.org/wiki/Bologna_Airport,"Guglielmo Marconi Airport, Borgo Panigale Airport"
4361,LIPQ,large_airport,Trieste Airport,45.827862,13.466672,39,EU,IT,IT-36,Ronchi dei Legionari/Trieste,yes,LIPQ,TRS,LIPQ,GO02,http://www.triesteairport.it,https://en.wikipedia.org/wiki/Trieste_%E2%80%93_Friuli_Venezia_Giulia_Airport,"Ronchi Dei Legionari Airport, Trieste-Ronchi dei Legionari Airport"
4362,LIPR,large_airport,Federico Fellini International Airport,44.020024,12.612198,40,EU,IT,IT-45,Rimini (RN),yes,LIPR,RMI,LIPR,RN01,http://www.riminiairport.com/english/,https://en.wikipedia.org/wiki/Federico_Fellini_International_Airport,"Rimini Miramare Airport, Rimini San Marino International Airport"
4366,LIPX,large_airport,Verona Villafranca Valerio Catullo Airport,45.394955,10.887303,239,EU,IT,IT-34,Caselle (VR),yes,LIPX,VRN,LIPX,VR10,http://www.aeroportoverona.it/,https://en.wikipedia.org/wiki/Verona_Airport,"Valerio Catullo, Villafranca International Airport, Valerio Catullo Airport, Villafranca Airport"
4368,LIPZ,large_airport,Venice Marco Polo Airport,45.505299,12.3519,7,EU,IT,IT-34,Venezia (VE),yes,LIPZ,VCE,LIPZ,VE05,http://www.veniceairport.com/core/index.jsp?_requestid=37300&language=en,https://en.wikipedia.org/wiki/Venice_Marco_Polo_Airport,"Venezia-Tessera, Venice-Tessera"
4372,LIRF,large_airport,Rome–Fiumicino Leonardo da Vinci International Airport,41.804532,12.251998,13,EU,IT,IT-62,Rome,yes,LIRF,FCO,LIRF,RM11,http://www.adr.it/portal/portal/adr/Fiumicino/Leonardo_da_vinci/Header_Window?action=2,https://en.wikipedia.org/wiki/Rome_Fiumicino_Airport,"ROM, Rome Fiumicino Airport, Fiumicino Airport"
4378,LIRN,large_airport,Naples International Airport,40.886002,14.2908,294,EU,IT,IT-72,Napoli,yes,LIRN,NAP,LIRN,NA01,http://www.portal.gesac.it/,https://en.wikipedia.org/wiki/Naples_International_Airport,"Capodichino Airport, Aeroporto Internazionale di Napoli-Capodichino Ugo Niutta, Ariopuorto 'nternazziunale 'e Napule-Capodichino Ugo Niutta"
4379,LIRP,large_airport,Pisa International Airport,43.683899,10.3927,6,EU,IT,IT-52,Pisa (PI),yes,LIRP,PSA,LIRP,PI05,http://www.pisa-airport.com/,https://en.wikipedia.org/wiki/Pisa_International_Airport,"Galileo Galilei Airport, San Giusto Airport"
4380,LIRQ,large_airport,"Florence Airport, Peretola",43.808558,11.202822,142,EU,IT,IT-52,Firenze (FI),yes,LIRQ,FLR,LIRQ,FI04,http://www.aeroporto.firenze.it/,https://en.wikipedia.org/wiki/Florence_Airport,"Firenze, Amerigo Vespucci Airport, Aeroporto di Firenze-Peretola"
4384,LIRZ,large_airport,Perugia San Francesco d'Assisi – Umbria International Airport,43.095901,12.5132,697,EU,IT,IT-55,Perugia (PG),yes,LIRZ,PEG,LIRZ,PG10,http://www.airport.umbria.it/,https://en.wikipedia.org/wiki/Perugia_San_Francesco_d%27Assisi_%E2%80%93_Umbria_International_Airport,"Perugia San Francesco d'Assisi, Perugia Sant'Egidio Airport, Umbria International Airport"
4386,LJLJ,large_airport,Ljubljana Jože Pučnik Airport,46.223701,14.4576,1273,EU,SI,SI-012,Zgornji Brnik,yes,LJLJ,LJU,LJLJ,,http://www.lju-airport.si/,https://en.wikipedia.org/wiki/Ljubljana_Jo%C5%BEe_Pu%C4%8Dnik_Airport,"Ljubljana, Kranj"
4393,LKCS,large_airport,České Budějovice South Bohemian Airport,48.948171,14.42832,1417,EU,CZ,CZ-JC,České Budějovice,yes,LKCS,JCL,LKCS,,https://www.airport-cb.cz/,https://en.wikipedia.org/wiki/%C4%8Cesk%C3%A9_Bud%C4%9Bjovice_Airport,Jihočeské letiště České Budějovice
4399,LKKV,large_airport,Karlovy Vary Airport,50.202999,12.915,1989,EU,CZ,CZ-KA,Karlovy Vary,yes,LKKV,KLV,LKKV,,http://www.airport-k-vary.cz/indexEN.php,https://en.wikipedia.org/wiki/Karlovy_Vary_Airport,Karlovy Vary International
4402,LKMT,large_airport,Leoš Janáček Airport Ostrava,49.696301,18.111099,844,EU,CZ,CZ-MO,Mošnov,yes,LKMT,OSR,LKMT,,http://www.airport-ostrava.cz/en/,https://en.wikipedia.org/wiki/Ostrava_Leos_Janacek_Airport,Ostrava-Mošnov International Airport
4405,LKPD,large_airport,Pardubice Airport,50.015049,15.73981,741,EU,CZ,CZ-PA,Pardubice,yes,LKPD,PED,LKPD,,http://www.airport-pardubice.cz/?lang=en,https://en.wikipedia.org/wiki/Pardubice_Airport,Letiště Pardubice
4408,LKPR,large_airport,Václav Havel Airport Prague,50.100874,14.259911,1247,EU,CZ,CZ-PR,Prague,yes,LKPR,PRG,LKPR,,http://www.prg.aero,https://en.wikipedia.org/wiki/Václav_Havel_Airport_Prague,"Letiště Praha-Ruzyně, Ruzyně International Airport, Letiště Václava Havla Praha"
4427,LMML,large_airport,Malta International Airport,35.845932,14.491546,300,EU,MT,MT-25,Valletta,yes,LMML,MLA,LMML,,https://www.maltairport.com/,https://en.wikipedia.org/wiki/Malta_International_Airport,"Luqa Airport, Valletta, Gudja, RAF Luqa"
4430,LOWG,large_airport,Graz Airport,46.9911,15.4396,1115,EU,AT,AT-6,Feldkirchen bei Graz,yes,LOWG,GRZ,LOWG,,http://www.flughafen-graz.at/,https://en.wikipedia.org/wiki/Graz_Airport,"Thalerhof Airport, Fliegerhorst Nittner, Nittner Air Base, Christophorus 12"
4431,LOWI,large_airport,Innsbruck Airport,47.260201,11.344,1907,EU,AT,AT-7,Innsbruck,yes,LOWI,INN,LOWI,,http://www.innsbruck-airport.com/,https://en.wikipedia.org/wiki/Innsbruck_Airport,Kranebitten Airport
4435,LOWK,large_airport,Klagenfurt Airport,46.642502,14.3377,1472,EU,AT,AT-2,Klagenfurt am Wörthersee,yes,LOWK,KLU,LOWK,,http://www.klagenfurt-airport.com/,https://en.wikipedia.org/wiki/Klagenfurt_Airport,"Kärnten Airport, Christophorus 11"
4432,LOWL,large_airport,Linz-Hörsching Airport,48.235362,14.188128,980,EU,AT,AT-4,Linz,yes,LOWL,LNZ,LOWL,,http://www.flughafen-linz.at/,https://en.wikipedia.org/wiki/Linz_Airport,"Blue Danube Airport, Vogler Air Base, Fliegerhorst Vogler, Christophorus 10"
4433,LOWS,large_airport,Salzburg Airport,47.793301,13.0043,1411,EU,AT,AT-5,Salzburg,yes,LOWS,SZG,LOWS,,http://www.salzburg-airport.com/,https://en.wikipedia.org/wiki/Salzburg_Airport,Christophorus 6
4434,LOWW,large_airport,Vienna International Airport,48.110298,16.5697,600,EU,AT,AT-9,Vienna,yes,LOWW,VIE,LOWW,,http://www.viennaairport.com/en/,https://en.wikipedia.org/wiki/Vienna_International_Airport,
4448,LPFR,large_airport,Faro - Gago Coutinho International Airport,37.015909,-7.970939,24,EU,PT,PT-08,Faro,yes,LPFR,FAO,LPFR,,http://faroairport.pt,https://en.wikipedia.org/wiki/Faro_Airport_(Portugal),
4452,LPMA,large_airport,Cristiano Ronaldo International Airport,32.697812,-16.774613,192,EU,PT,PT-30,Funchal,yes,LPMA,FNC,LPMA,,http://www.aeroportomadeira.pt,https://en.wikipedia.org/wiki/Madeira_Airport,"Madeira, Funchal"
4456,LPPD,large_airport,João Paulo II Airport,37.7411994934,-25.6979007721,259,EU,PT,PT-20,Ponta Delgada,yes,LPPD,PDL,LPPD,,http://www.ana.pt/en-US/Aeroportos/acores/PontaDelgada/Pages/Homepage-PontaDelgada.aspx,https://en.wikipedia.org/wiki/Jo%C3%A3o_Paulo_II_Airport,
4459,LPPR,large_airport,Francisco de Sá Carneiro Airport,41.2481002808,-8.68138980865,228,EU,PT,PT-13,Porto,yes,LPPR,OPO,LPPR,,http://www.ana.pt/en-US/Aeroportos/porto/Porto/Pages/Homepage-Porto.aspx,https://en.wikipedia.org/wiki/Francisco_S%C3%A1_Carneiro_Airport,
4461,LPPT,large_airport,Lisbon Humberto Delgado Airport,38.7813,-9.13592,374,EU,PT,PT-11,Lisbon,yes,LPPT,LIS,LPPT,,https://lisbonairport.pt,https://en.wikipedia.org/wiki/Lisbon_Airport,"Lisboa, Portela"
4467,LQBK,large_airport,Banja Luka International Airport,44.941399,17.297501,400,EU,BA,BA-SRP,Mahovljani,yes,LQBK,BNX,LQBK,,http://www.banjaluka-airport.com/,https://en.wikipedia.org/wiki/Banja_Luka_International_Airport,Mahovljani Airport
4468,LQMO,large_airport,Mostar International Airport,43.282489,17.846101,156,EU,BA,BA-BIH,Mostar,yes,LQMO,OMO,LQMO,,http://www.mostar-airport.ba/,https://en.wikipedia.org/wiki/Mostar_International_Airport,
4469,LQSA,large_airport,Sarajevo International Airport,43.8246,18.331499,1708,EU,BA,BA-BIH,Sarajevo,yes,LQSA,SJJ,LQSA,,http://www.sarajevo-airport.ba/,https://en.wikipedia.org/wiki/Sarajevo_International_Airport,
4470,LQTZ,large_airport,Tuzla International Airport,44.459858,18.723627,784,EU,BA,BA-BIH,Dubrave Gornje,yes,LQTZ,TZL,LQTZ,,http://www.tuzla-airport.ba/,https://en.wikipedia.org/wiki/Tuzla_International_Airport,
4472,LRBC,large_airport,Bacău George Enescu International Airport,46.5219,26.910299,607,EU,RO,RO-BC,Bacău,yes,LRBC,BCM,LRBC,,http://www.bacauairport.ro/,https://en.wikipedia.org/wiki/Bac%C4%83u_International_Airport,"Bacau, RoAF 95th Air Base"
333670,LRBV,large_airport,Brașov-Ghimbav International Airport,45.70558,25.522871,1740,EU,RO,RO-BV,Brașov (Ghimbav),yes,LRBV,GHV,LRBV,,https://brasovairport.ro/,https://en.wikipedia.org/wiki/Brașov-Ghimbav_International_Airport,Aeroportul Internațional Brașov-Ghimbav
4475,LRCK,large_airport,Mihail Kogălniceanu International Airport,44.362202,28.4883,353,EU,RO,RO-CT,Constanța,yes,LRCK,CND,LRCK,,http://www.mk-airport.ro/,https://en.wikipedia.org/wiki/Mihail_Kog%C4%83lniceanu_International_Airport,"Constanța, Mihail Kogalniceanu, RoAF 57th Air Base"
4476,LRCL,large_airport,Avram Iancu Cluj International Airport,46.786042,23.685733,1039,EU,RO,RO-CJ,Cluj-Napoca,yes,LRCL,CLJ,LRCL,,http://www.airportcluj.ro/,https://en.wikipedia.org/wiki/Cluj_International_Airport,Someşeni Airport
4479,LRCV,large_airport,Craiova International Airport,44.3181,23.888599,626,EU,RO,RO-DJ,Craiova,yes,LRCV,CRA,LRCV,,http://www.aeroportcraiova.ro/,https://en.wikipedia.org/wiki/Craiova_International_Airport,
4480,LRIA,large_airport,Iaşi International Airport,47.179633,27.621431,411,EU,RO,RO-IS,Iaşi,yes,LRIA,IAS,LRIA,,https://www.aeroport-iasi.ro/,https://en.wikipedia.org/wiki/Ia%C5%9Fi_International_Airport,"Iasi, Jassy"
4481,LROD,large_airport,Oradea International Airport,47.025299,21.9025,465,EU,RO,RO-BH,Oradea,yes,LROD,OMR,LROD,,https://www.aeroportoradea.ro/,https://en.wikipedia.org/wiki/Oradea_International_Airport,
4482,LROP,large_airport,Bucharest Henri Coandă International Airport,44.571792,26.103285,314,EU,RO,RO-IF,Otopeni,yes,LROP,OTP,LROP,,http://www.bucharestairports.ro/en/,https://en.wikipedia.org/wiki/Henri_Coand%C4%83_International_Airport,"BUH, Otopeni Airport, RoAF 90th Airlift Base"
4483,LRSB,large_airport,Sibiu International Airport,45.785841,24.086666,1496,EU,RO,RO-SB,Sibiu,yes,LRSB,SBZ,LRSB,,http://www.sibiuairport.ro/,https://en.wikipedia.org/wiki/Sibiu_International_Airport,
4485,LRSV,large_airport,Suceava Ștefan cel Mare International Airport,47.6875,26.354099,1375,EU,RO,RO-SV,Suceava,yes,LRSV,SCV,LRSV,,http://www.aeroportsuceava.ro/index_en.html,https://en.wikipedia.org/wiki/Suceava_Airport,
4488,LRTR,large_airport,Timișoara Traian Vuia International Airport,45.809898,21.3379,348,EU,RO,RO-TM,Timişoara,yes,LRTR,TSR,LRTR,,http://www.aerotim.ro/,https://en.wikipedia.org/wiki/Traian_Vuia_International_Airport,"RoAF 93rd Air Base, Giarmata Airport, Temesvár, Temeschburg, Temeswar, Temeschwar, Тимишоара, Темишвар, Temišvar, Banat"
4490,LSGG,large_airport,Geneva International Airport,46.238098,6.10895,1411,EU,CH,CH-GE,Geneva,yes,LSGG,GVA,LSGG,,https://www.gva.ch/,https://en.wikipedia.org/wiki/Geneva_Airport,Cointrin Airport
4505,LSZH,large_airport,Zürich Airport,47.458056,8.548056,1417,EU,CH,CH-ZH,Zurich,yes,LSZH,ZRH,LSZH,,http://www.zurich-airport.com/,https://en.wikipedia.org/wiki/Zurich_Airport,
317457,LTFM,large_airport,İstanbul Airport,41.274874,28.732136,325,EU,TR,TR-34,Istanbul,yes,LTFM,IST,LTFM,,https://www.istairport.com/,https://en.wikipedia.org/wiki/Istanbul_Airport,Arnavutköy
4571,LUKK,large_airport,Chişinău International Airport,46.92774,28.931704,399,EU,MD,MD-CU,Chişinău,yes,LUKK,RMO,LUKK,,http://www.airport.md/,https://en.wikipedia.org/wiki/Chi%C5%9Fin%C4%83u_International_Airport,KIV
4572,LWOH,large_airport,Ohrid St. Paul the Apostle Airport,41.18,20.7423,2313,EU,MK,MK-003,Ohrid,yes,LWOH,OHD,LWOH,,https://ohd.airports.com.mk/,https://en.wikipedia.org/wiki/Ohrid_Airport,
4573,LWSK,large_airport,Skopje International Airport,41.958087,21.622581,781,EU,MK,MK-008,Ilinden,yes,LWSK,SKP,LWSK,,http://skp.airports.com.mk/,https://en.wikipedia.org/wiki/Skopje_Airport,Petrovec
4574,LXGB,large_airport,Gibraltar Airport,36.151679,-5.34978,12,EU,GI,GI-U-A,Gibraltar,yes,LXGB,GIB,LXGB,,https://www.gibraltarairport.gi/,https://en.wikipedia.org/wiki/Gibraltar_Airport,
4610,LYBE,large_airport,Belgrade Nikola Tesla Airport,44.818401,20.309099,335,EU,RS,RS-00,Belgrade,yes,LYBE,BEG,LYBE,,http://www.beg.aero/,https://en.wikipedia.org/wiki/Belgrade_Nikola_Tesla_Airport,"Beograd, Surčin Airport"
4612,LYNI,large_airport,Niš Constantine the Great Airport,43.336538,21.856242,648,EU,RS,RS-20,Niš,yes,LYNI,INI,LYNI,,https://ini.aerodromisrbije.rs/en/,https://en.wikipedia.org/wiki/Ni%C5%A1_Constantine_the_Great_Airport,Aerodrom Konstantin Veliki Niš
4613,LYPG,large_airport,Podgorica Airport / Podgorica Golubovci Airbase,42.359402,19.2519,141,EU,ME,ME-16,Podgorica,yes,LYPG,TGD,LYPG,,https://montenegroairports.com/en/podgorica-airport/,https://en.wikipedia.org/wiki/Podgorica_Airport,"Podgorica Airbase, Golubovci Airbase, Аеродром Подгорица, Aerodrom Podgorica"
4617,LZIB,large_airport,M. R. Štefánik Airport,48.1702,17.2127,436,EU,SK,SK-BL,Bratislava,yes,LZIB,BTS,LZIB,,,https://en.wikipedia.org/wiki/M._R._%C5%A0tef%C3%A1nik_Airport,
6467,UKBB,large_airport,Boryspil International Airport,50.345001,30.894699,427,EU,UA,UA-32,Boryspil,no,UKBB,KBP,UKBB,,https://kbp.aero/en/,https://en.wikipedia.org/wiki/Boryspil_International_Airport,"Borispol, Міжнародний аеропорт ""Бориспіль"", Kyiv"
6474,UKFF,large_airport,Simferopol International Airport,45.0522,33.975101,639,EU,UA,UA-43,Simferopol,no,UKFF,SIP,UKFF,URFF,https://new.sipaero.ru/,https://en.wikipedia.org/wiki/Simferopol_International_Airport,
6481,UKLL,large_airport,Lviv International Airport,49.8125,23.9561,1071,EU,UA,UA-46,Lviv,no,UKLL,LWO,UKLL,,http://www.airport.lviv.ua/index.php?id=home&L=1,https://en.wikipedia.org/wiki/Lviv_International_Airport,"Міжнародний аеропорт ""Львів"""
6487,UKOO,large_airport,Odesa International Airport,46.427196,30.672649,172,EU,UA,UA-51,Odesa,no,UKOO,ODS,,,https://www.airport.od.ua/,https://en.wikipedia.org/wiki/Odessa_International_Airport,"ODS, UKOO, УКОО, Odesa Central, Міжнародний аеропорт Одеса, Odessa Airport"
6489,ULLI,large_airport,Pulkovo Airport,59.800301,30.262501,78,EU,RU,RU-SPE,St. Petersburg,yes,ULLI,LED,ULLI,,https://pulkovoairport.ru/,https://en.wikipedia.org/wiki/Pulkovo_Airport,"Аэропо́рт Пу́лково, Leningrad, Shosseynaya Airport, Saint Petersburg, Petrograd"
6490,ULMM,large_airport,Emperor Nicholas II Murmansk Airport,68.7817,32.750801,266,EU,RU,RU-MUR,Murmansk,yes,ULMM,MMK,ULMM,,http://www.airport-murmansk.ru/,https://en.wikipedia.org/wiki/Murmansk_Airport,"Аэропо́рт Му́рманска им. Николая II or Аэропорт Мурмаши, Мурманск"
6495,UMBB,large_airport,Brest International Airport,52.108138,23.89676,468,EU,BY,BY-BR,Brest,yes,UMBB,BQT,UMBB,,https://www.ban.by/filialy/brest,https://en.wikipedia.org/wiki/Brest_Airport,Аэропорт Брест
6498,UMKK,large_airport,Khrabrovo Airport,54.89161,20.598559,42,EU,RU,RU-KGD,Kaliningrad,yes,UMKK,KGD,UMKK,,https://kgdavia.ru/,https://en.wikipedia.org/wiki/Khrabrovo_Airport,"УМКК, Храброво"
6501,UMMS,large_airport,Minsk National Airport,53.888071,28.039964,670,EU,BY,BY-MI,Minsk,yes,UMMS,MSQ,UMMS,,http://airport.by/en,https://en.wikipedia.org/wiki/Minsk_National_Airport,"Нацыянальны аэрапорт, Minsk 2 Airport"
6507,UNNT,large_airport,Novosibirsk Tolmachevo Airport,55.019756,82.618675,365,EU,RU,RU-NVS,Novosibirsk,yes,UNNT,OVB,UNNT,,http://eng.tolmachevo.ru/,https://en.wikipedia.org/wiki/Tolmachevo_Airport,
6512,URKK,large_airport,Krasnodar Pashkovsky International Airport,45.034465,39.174215,118,EU,RU,RU-KDA,Krasnodar,yes,URKK,KRR,URKK,,https://krr.aero/en/,https://en.wikipedia.org/wiki/Pashkovsky_Airport,"Pashkovskiy Airport, Pashkovsky Airport"
6514,URMM,large_airport,Mineralnye Vody Airport,44.225101,43.081902,1054,EU,RU,RU-STA,Mineralnyye Vody,yes,URMM,MRV,URMM,,,https://en.wikipedia.org/wiki/Mineralnye_Vody_Airport,
326363,URRP,large_airport,Platov International Airport,47.493888,39.924722,213,EU,RU,RU-ROS,Rostov-on-Don,no,URRP,ROV,URRP,,,https://en.wikipedia.org/wiki/Platov_International_Airport,"УРРП, Международный аэропорт Платов"
6519,URSS,large_airport,Sochi International Airport,43.449902,39.9566,89,EU,RU,RU-KDA,Sochi,yes,URSS,AER,URSS,,https://aer.aero/en,https://en.wikipedia.org/wiki/Sochi_International_Airport,"Adler Airport, Black Sea, Международный Аэропорт Сочи"
6522,URWW,large_airport,Volgograd International Airport,48.78134,44.339194,482,EU,RU,RU-VGG,Volgograd,yes,URWW,VOG,URWW,,,https://en.wikipedia.org/wiki/Volgograd_International_Airport,"Gumrak Airport, Аэропорт Волгоград, Аэропорт Гумрак"
26395,UUDL,large_airport,Golden Ring Yaroslavl International Airport,57.560699,40.157398,287,EU,RU,RU-YAR,Tunoshna,yes,UUDL,IAR,UUDL,,,https://en.wikipedia.org/wiki/Tunoshna,
26396,UUEE,large_airport,Sheremetyevo International Airport,55.976858,37.41121,622,EU,RU,RU-MOS,Moscow,yes,UUEE,SVO,UUEE,,http://svo.aero/,https://en.wikipedia.org/wiki/Sheremetyevo_International_Airport,"УУЕЕ, Москва, MOW, Международный аэропорт Шереметьево, Alexander S. Pushkin"
26403,UWGG,large_airport,Nizhny Novgorod / Strigino International Airport,56.227351,43.785152,256,EU,RU,RU-NIZ,Nizhny Novgorod,yes,UWGG,GOJ,UWGG,,http://www.airportnn.ru/en/,https://en.wikipedia.org/wiki/Nizhny_Novgorod_International_Airport,"УВГГ, ЬВГГ, Нижний Новгород / Стригино"
42965,UWPS,large_airport,Saransk International Airport,54.12513,45.212257,676,EU,RU,RU-MO,Saransk,yes,UWPS,SKX,UWPS,,,https://en.wikipedia.org/wiki/Saransk_Airport,Аэропорт Саранск
26411,UWUU,large_airport,Ufa International Airport,54.557499,55.874401,449,EU,RU,RU-BA,Ufa,yes,UWUU,UFA,UWUU,,http://www.airportufa.ru/en/,https://en.wikipedia.org/wiki/Ufa_International_Airport,"УВУУ, Международный аэропорт Уфа"
26412,UWWW,large_airport,Kurumoch International Airport,53.504902,50.164299,477,EU,RU,RU-SAM,Samara,yes,UWWW,KUF,UWWW,,https://kuf.aero/en/,https://en.wikipedia.org/wiki/Kurumoch_International_Airport,"УВВВ, Самара / Курумоч"
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
2 117 BIAR large_airport Akureyri International Airport 65.656573 -18.072018 6 EU IS IS-6 Akureyri yes BIAR AEY BIAR https://www.innanlandsflugvellir.is/en/akureyri-airport/ https://en.wikipedia.org/wiki/Akureyri_Airport
3 123 BIKF large_airport Keflavik International Airport 63.985001 -22.6056 171 EU IS IS-2 Reykjavík yes BIKF KEF BIKF https://www.isavia.is/en/keflavik-airport https://en.wikipedia.org/wiki/Keflav%C3%ADk_International_Airport Keflavik Naval Air Station, REK
4 4614 BKPR large_airport Priština Adem Jashari International Airport 42.5728 21.035801 1789 EU XK XK-01 Prishtina yes BKPR PRN BKPR http://www.airportpristina.com/ https://en.wikipedia.org/wiki/Pristina_International_Airport LYPR, Pristina, Slatina Air Base
5 2155 EBBR large_airport Brussels Airport 50.901402 4.48444 175 EU BE BE-VBR Zaventem yes EBBR BRU EBBR http://www.brusselsairport.be/en/ https://en.wikipedia.org/wiki/Brussels_Airport Brussels National, Zaventem, EBMB
6 2163 EBOS large_airport Ostend-Bruges International Airport 51.1998 2.874673 13 EU BE BE-VWV Oostende yes EBOS OST EBOS http://www.ost.aero/ https://en.wikipedia.org/wiki/Ostend-Bruges_International_Airport Ostend-Bruges International Airport
7 301881 EDDB large_airport Berlin Brandenburg Airport 52.361738 13.502341 157 EU DE DE-BR Berlin yes EDDB BER EDDB https://ber.berlin-airport.de/ https://en.wikipedia.org/wiki/Berlin_Brandenburg_Airport Willy Brandt
8 2210 EDDC large_airport Dresden Airport 51.134123 13.767831 755 EU DE DE-SN Dresden yes EDDC DRS EDDC http://www.dresden-airport.de/en/index.html https://en.wikipedia.org/wiki/Dresden_Airport ETDN
9 2211 EDDE large_airport Erfurt-Weimar Airport 50.978281 10.960713 1036 EU DE DE-TH Erfurt yes EDDE ERF EDDE http://www.airport-erfurt.de/ https://en.wikipedia.org/wiki/Erfurt_Airport Flughafen Erfurt-Weimar
10 2212 EDDF large_airport Frankfurt Main Airport 50.026706 8.55835 364 EU DE DE-HE Frankfurt am Main yes EDDF FRA EDDF https://www.frankfurt-airport.de/ https://en.wikipedia.org/wiki/Frankfurt_Airport EDAF, Frankfurt am Main, Frankfurt Main, Rhein-Main Air Base, Zeppelinheim
11 2213 EDDG large_airport Münster Osnabrück Airport 52.133816 7.688482 160 EU DE DE-NW Greven yes EDDG FMO EDDG https://www.fmo.de/ https://en.wikipedia.org/wiki/M%C3%BCnster_Osnabr%C3%BCck_International_Airport
12 2214 EDDH large_airport Hamburg Helmut Schmidt Airport 53.630402 9.98823 53 EU DE DE-HH Hamburg yes EDDH HAM EDDH https://www.hamburg-airport.de/en/ https://en.wikipedia.org/wiki/Hamburg_Airport Hamburg-Fuhlsbüttel Airport
13 2216 EDDK large_airport Cologne Bonn Airport 50.865898 7.14274 302 EU DE DE-NW Köln (Cologne) yes EDDK CGN EDDK http://www.koeln-bonn-airport.de/en/ https://en.wikipedia.org/wiki/Cologne_Bonn_Airport Köln
14 2217 EDDL large_airport Düsseldorf Airport 51.289501 6.76678 147 EU DE DE-NW Düsseldorf yes EDDL DUS EDDL http://www.dus.com/dus_en/ https://en.wikipedia.org/wiki/D%C3%BCsseldorf_Airport
15 2218 EDDM large_airport Munich Airport 48.353802 11.7861 1487 EU DE DE-BY Munich yes EDDM MUC EDDM http://www.munich-airport.com/ https://en.wikipedia.org/wiki/Munich_Airport Franz Josef Strauss Airport, Flughafen München Franz Josef Strauß
16 2219 EDDN large_airport Nuremberg Airport 49.498699 11.078056 1046 EU DE DE-BY Nuremberg yes EDDN NUE EDDN https://www.airport-nuernberg.de/english https://en.wikipedia.org/wiki/Nuremberg_Airport Nürnberg
17 2220 EDDP large_airport Leipzig/Halle Airport 51.420657 12.232705 465 EU DE DE-SN Schkeuditz yes EDDP LEJ EDDP https://www.mdf-ag.com/ https://en.wikipedia.org/wiki/Leipzig/Halle_Airport Schkeuditz Airport
18 2222 EDDS large_airport Stuttgart Airport 48.689899 9.22196 1276 EU DE DE-BW Stuttgart yes EDDS STR EDDS http://www.flughafen-stuttgart.de/ https://en.wikipedia.org/wiki/Stuttgart_Airport
19 2224 EDDV large_airport Hannover Airport 52.461102 9.68508 183 EU DE DE-NI Hannover yes EDDV HAJ EDDV https://www.hannover-airport.de https://en.wikipedia.org/wiki/Hanover/Langenhagen_International_Airport
20 2225 EDDW large_airport Bremen Airport 53.046786 8.78932 14 EU DE DE-HB Bremen yes EDDW BRE EDDW https://www.bremen-airport.com https://en.wikipedia.org/wiki/Bremen_Airport
21 2227 EDFH large_airport Frankfurt-Hahn Airport 49.946353 7.261734 1649 EU DE DE-RP Frankfurt am Main (Lautzenhausen) yes EDFH HHN EDFH https://www.hahn-airport.de/en/home https://en.wikipedia.org/wiki/Frankfurt-Hahn_Airport
22 2237 EDJA large_airport Memmingen Allgau Airport 47.988092 10.238248 2077 EU DE DE-BY Memmingen yes EDJA FMM EDJA https://en.wikipedia.org/wiki/Memmingen_Airport ETSM
23 2244 EDLP large_airport Paderborn Lippstadt Airport 51.612527 8.617459 699 EU DE DE-NW Büren yes EDLP PAD EDLP https://en.wikipedia.org/wiki/Paderborn_Lippstadt_Airport
24 2247 EDLW large_airport Dortmund Airport 51.518299 7.61224 425 EU DE DE-NW Dortmund yes EDLW DTM EDLW https://www.dortmund-airport.com/ https://en.wikipedia.org/wiki/Dortmund_Airport
25 2256 EDNY large_airport Bodensee Airport Friedrichshafen 47.671299 9.51149 1367 EU DE DE-BW Friedrichshafen yes EDNY FDH EDNY https://www.bodensee-airport.eu https://en.wikipedia.org/wiki/Friedrichshafen_Airport Bodensee Airport Friedrichshafen
26 2270 EDSB large_airport Karlsruhe Baden-Baden Airport 48.7794 8.0805 408 EU DE DE-BW Rheinmünster yes EDSB FKB EDSB http://www.baden-airpark.de/startseite.html https://en.wikipedia.org/wiki/Baden_Airpark Baden Airpark
27 2285 EDVK large_airport Kassel Airport 51.418395 9.391638 820 EU DE DE-HE Calden yes EDVK KSF EDVK http://www.flughafenkassel.de/ https://en.wikipedia.org/wiki/Kassel_Airport Kassel-Calden
28 2301 EETN large_airport Lennart Meri Tallinn Airport 59.413246 24.83264 131 EU EE EE-37 Tallinn yes EETN TLL EETN https://airport.ee/ https://en.wikipedia.org/wiki/Tallinn_Airport Tallinna Lennujaam
29 2307 EFHK large_airport Helsinki Vantaa Airport 60.318363 24.963341 179 EU FI FI-18 Helsinki (Vantaa) yes EFHK HEL EFHK https://www.finavia.fi/en/airports/helsinki-airport https://en.wikipedia.org/wiki/Helsinki_Airport
30 2314 EFIV large_airport Ivalo Airport 68.6073 27.4053 481 EU FI FI-10 Ivalo yes EFIV IVL EFIV http://www.finavia.fi/en/ivalo/ https://en.wikipedia.org/wiki/Ivalo_Airport
31 2325 EFKT large_airport Kittilä International Airport 67.700996 24.8468 644 EU FI FI-10 Kittilä yes EFKT KTT EFKT http://www.finavia.fi/en/kittila/ https://en.wikipedia.org/wiki/Kittil%C3%A4_Airport
32 2326 EFKU large_airport Kuopio Airport 63.007099 27.7978 323 EU FI FI-15 Kuopio / Siilinjärvi yes EFKU KUO EFKU https://www.finavia.fi/en/kuopio/ https://en.wikipedia.org/wiki/Kuopio_Airport
33 2329 EFLP large_airport Lappeenranta Airport 61.044601 28.144743 349 EU FI FI-02 Lappeenranta yes EFLP LPP EFLP https://lppairport.fi/en https://en.wikipedia.org/wiki/Lappeenranta_Airport
34 2334 EFOU large_airport Oulu Airport 64.930099 25.354601 47 EU FI FI-14 Oulu / Oulunsalo yes EFOU OUL EFOU https://www.finavia.fi/en/oulu/ https://en.wikipedia.org/wiki/Oulu_Airport
35 2341 EFRO large_airport Rovaniemi Airport 66.563327 25.829751 642 EU FI FI-10 Rovaniemi yes EFRO RVN EFRO http://www.finavia.fi/en/rovaniemi/ https://en.wikipedia.org/wiki/Rovaniemi_Airport
36 2347 EFTP large_airport Tampere-Pirkkala Airport 61.414101 23.604401 390 EU FI FI-11 Tampere / Pirkkala yes EFTP TMP EFTP https://www.finavia.fi/en/tampere-pirkkala/ https://en.wikipedia.org/wiki/Tampere-Pirkkala_Airport
37 2349 EFTU large_airport Turku Airport 60.514099 22.2628 161 EU FI FI-19 Turku yes EFTU TKU EFTU https://www.finavia.fi/en/turku/ https://en.wikipedia.org/wiki/Turku_Airport
38 2351 EFVA large_airport Vaasa Airport 63.05023 21.762543 19 EU FI FI-12 Vaasa yes EFVA VAA EFVA http://www.finavia.fi/en/vaasa/ https://en.wikipedia.org/wiki/Vaasa_Airport
39 2385 EGAA large_airport Belfast International Airport 54.6575012207 -6.2158298492399995 268 EU GB GB-NIR Belfast yes EGAA BFS EGAA http://www.belfastairport.com/ https://en.wikipedia.org/wiki/Belfast_International_Airport Aldergrove, RAF Aldergrove
40 2389 EGBB large_airport Birmingham Airport 52.453899 -1.74803 327 EU GB GB-ENG Birmingham, West Midlands yes EGBB BHX EGBB https://www.birminghamairport.co.uk/ https://en.wikipedia.org/wiki/Birmingham_Airport Birmingham International Airport
41 2398 EGCC large_airport Manchester Airport 53.349375 -2.279521 257 EU GB GB-ENG Manchester, Greater Manchester yes EGCC MAN EGCC http://www.manchesterairport.co.uk/ https://en.wikipedia.org/wiki/Manchester_Airport Ringway Airport, RAF Ringway
42 2415 EGFF large_airport Cardiff International Airport 51.396702 -3.34333 220 EU GB GB-WLS Cardiff yes EGFF CWL EGFF http://www.cwlfly.com/ https://en.wikipedia.org/wiki/Cardiff_Airport Caerdydd, Cardiff International Airport, Maes Awyr Caerdydd, Rhoose, Y Rhws
43 2417 EGGD large_airport Bristol Airport 51.382326 -2.716453 622 EU GB GB-ENG Bristol yes EGGD BRS EGGD http://www.bristolairport.co.uk/ https://en.wikipedia.org/wiki/Bristol_Airport Lulsgate Bottom
44 2418 EGGP large_airport Liverpool John Lennon Airport 53.334863 -2.849637 80 EU GB GB-ENG Liverpool yes EGGP LPL EGGP http://www.liverpoolairport.com/ https://en.wikipedia.org/wiki/Liverpool_John_Lennon_Airport
45 2434 EGLL large_airport London Heathrow Airport 51.470748 -0.459909 83 EU GB GB-ENG London yes EGLL LHR EGLL http://www.heathrow.com/ https://en.wikipedia.org/wiki/Heathrow_Airport LON, Londres
46 2444 EGNM large_airport Leeds Bradford Airport 53.865898 -1.66057 681 EU GB GB-ENG Leeds, West Yorkshire yes EGNM LBA EGNM http://www.lbia.co.uk/ https://en.wikipedia.org/wiki/Leeds_Bradford_International_Airport
47 2447 EGNS large_airport Isle of Man Airport 54.083081 -4.623871 52 EU IM IM-U-A Castletown yes EGNS IOM EGNS http://www.iom-airport.com/ https://en.wikipedia.org/wiki/Isle_of_Man_Airport Ronaldsway, Douglas
48 2448 EGNT large_airport Newcastle International Airport 55.037958 -1.689577 266 EU GB GB-ENG Newcastle upon Tyne, Tyne and Wear yes EGNT NCL EGNT http://www.newcastleairport.com/ https://en.wikipedia.org/wiki/Newcastle_International_Airport
49 2450 EGNX large_airport East Midlands Airport 52.8311 -1.32806 306 EU GB GB-ENG Nottingham, Leicestershire yes EGNX EMA EGNX http://www.eastmidlandsairport.com/ https://en.wikipedia.org/wiki/East_Midlands_Airport RAF Castle Donington
50 2459 EGPD large_airport Aberdeen International Airport 57.2019 -2.19778 215 EU GB GB-SCT Aberdeen yes EGPD ABZ EGPD http://www.aberdeenairport.com/ https://en.wikipedia.org/wiki/Aberdeen_Airport Dyce
51 2461 EGPF large_airport Glasgow Airport 55.871899 -4.43306 26 EU GB GB-SCT Glasgow yes EGPF GLA EGPF http://www.glasgowairport.com/ https://en.wikipedia.org/wiki/Glasgow_International_Airport Port-adhair Eadar-nàiseanta Ghlaschu, RAF Abbotsinch, RAF Ayr
52 2462 EGPH large_airport Edinburgh Airport 55.950145 -3.372288 135 EU GB GB-SCT Ingliston, Edinburgh yes EGPH EDI EGPH http://www.edinburghairport.com/ https://en.wikipedia.org/wiki/Edinburgh_Airport
53 2513 EHAM large_airport Amsterdam Airport Schiphol 52.308601 4.76389 -11 EU NL NL-NH Amsterdam yes EHAM AMS EHAM https://www.schiphol.nl/ https://en.wikipedia.org/wiki/Amsterdam_Airport_Schiphol
54 2515 EHBK large_airport Maastricht Aachen Airport 50.911087 5.769401 375 EU NL NL-LI Maastricht yes EHBK MST EHBK https://en.wikipedia.org/wiki/Maastricht_Aachen_Airport
55 2518 EHEH large_airport Eindhoven Airport 51.4500999451 5.37452983856 74 EU NL NL-NB Eindhoven yes EHEH EIN EHEH http://www.eindhovenairport.nl/ https://en.wikipedia.org/wiki/Eindhoven_Airport Welschap Airport
56 2519 EHGG large_airport Groningen Airport Eelde 53.119107 6.577652 17 EU NL NL-DR Groningen yes EHGG GRQ EHGG https://en.wikipedia.org/wiki/Groningen_Airport_Eelde
57 2524 EHRD large_airport Rotterdam The Hague Airport 51.956902 4.43722 -15 EU NL NL-ZH Rotterdam yes EHRD RTM EHRD https://www.rotterdamthehagueairport.nl/ https://en.wikipedia.org/wiki/Rotterdam_The_Hague_Airport Vliegveld Zestienhoven
58 2530 EICK large_airport Cork International Airport 51.841301 -8.49111 502 EU IE IE-CO Cork yes EICK ORK EICK http://www.corkairport.com/ https://en.wikipedia.org/wiki/Cork_Airport Aerfort Chorcaí
59 2533 EIDW large_airport Dublin Airport 53.428713 -6.262121 242 EU IE IE-D Dublin yes EIDW DUB EIDW http://www.dublinairport.com/ https://en.wikipedia.org/wiki/Dublin_Airport Aerfort Bhaile Átha Cliath
60 2534 EIKN large_airport Ireland West Airport Knock 53.910439 -8.817002 665 EU IE IE-MO Charlestown yes EIKN NOC EIKN http://www.irelandwestairport.com/ https://en.wikipedia.org/wiki/Ireland_West_Airport_Knock Connaught, Horan International Airport
61 2537 EINN large_airport Shannon Airport 52.702 -8.92482 46 EU IE IE-CE Shannon yes EINN SNN EINN http://www.shannonairport.com/ https://en.wikipedia.org/wiki/Shannon_Airport
62 2540 EKAH large_airport Aarhus Airport 56.303331 10.618286 82 EU DK DK-82 Aarhus yes EKAH AAR EKAH http://www.aar.dk/ https://en.wikipedia.org/wiki/Aarhus_Airport Tirstrup
63 2541 EKBI large_airport Billund Airport 55.740335 9.157019 247 EU DK DK-83 Billund yes EKBI BLL EKBI http://www.billund-airport.dk/?sc_lang=en https://en.wikipedia.org/wiki/Billund_Airport
64 2542 EKCH large_airport Copenhagen Kastrup Airport 55.617900848389 12.656000137329 17 EU DK DK-84 Copenhagen yes EKCH CPH EKCH http://www.cph.dk/en/ https://en.wikipedia.org/wiki/Copenhagen_Airport København, Malmö
65 2549 EKOD large_airport Odense Hans Christian Andersen Airport 55.475307 10.327206 56 EU DK DK-83 Odense yes EKOD ODE EKOD https://en.wikipedia.org/wiki/Hans_Christian_Andersen_Airport Beldringe
66 2559 EKVG large_airport Vágar Airport 62.063256 -7.275782 280 EU FO FO-U-A Vágar yes EKVG FAE EKVG https://www.fae.fo/ https://en.wikipedia.org/wiki/V%C3%A1gar_Airport Faroes, RAF Vágar
67 2562 EKYT large_airport Aalborg Airport 57.094763 9.84993 10 EU DK DK-81 Aalborg yes EKYT AAL EKYT http://www.aal.dk https://en.wikipedia.org/wiki/Aalborg_Airport
68 2563 ELLX large_airport Luxembourg-Findel International Airport 49.626845 6.212134 1234 EU LU LU-L Luxembourg yes ELLX LUX ELLX https://www.lux-airport.lu/en/ https://en.wikipedia.org/wiki/Luxembourg-Findel_International_Airport
69 2564 ENAL large_airport Ålesund Airport 62.560443 6.110845 69 EU NO NO-15 Ålesund yes ENAL AES ENAL https://www.avinor.no/airport/alesund/ https://en.wikipedia.org/wiki/%C3%85lesund_Airport%2C_Vigra Vigra, Ålesund Vigra Airport
70 2569 ENBO large_airport Bodø Airport 67.269203 14.3653 42 EU NO NO-18 Bodø yes ENBO BOO ENBO http://www.avinor.no/en/airport/bodo/ https://en.wikipedia.org/wiki/Bod%C3%B8_Airport Bodø Air Force Base, Bodø Main Air Station
71 2570 ENBR large_airport Bergen Airport, Flesland 60.2934 5.21814 170 EU NO NO-46 Bergen yes ENBR BGO ENBR http://www.avinor.no/en/airport/bergen https://en.wikipedia.org/wiki/Bergen_Airport%2C_Flesland
72 2572 ENCN large_airport Kristiansand Airport 58.204201 8.08537 57 EU NO NO-42 Kristiansand(Kjevik) yes ENCN KRS ENCN http://www.avinor.no/en/airport/kristiansand https://en.wikipedia.org/wiki/Kristiansand_Airport%2C_Kjevik Kjevik Airport
73 2575 ENEV large_airport Harstad/Narvik Airport 68.491302 16.678101 84 EU NO NO-18 Evenes yes ENEV EVE ENEV https://avinor.no/en/airport/harstadnarvik-airport/ https://en.wikipedia.org/wiki/Harstad/Narvik_Airport%2C_Evenes Harstad/Narvik lufthavn, Evenes, Evenes Airport
74 2578 ENGM large_airport Oslo-Gardermoen International Airport 60.193901 11.1004 681 EU NO NO-32 Oslo (Gardermoen) yes ENGM OSL ENGM https://avinor.no/en/airport/oslo-airport https://en.wikipedia.org/wiki/Oslo_Airport,_Gardermoen
75 2599 ENTC large_airport Tromsø Airport 69.683296 18.9189 31 EU NO NO-55 Tromsø yes ENTC TOS ENTC http://www.avinor.no/en/airport/tromso https://en.wikipedia.org/wiki/Troms%C3%B8_Airport Langnes, Tromsøya, Tromso
76 2600 ENTO large_airport Sandefjord Airport, Torp 59.186699 10.2586 286 EU NO NO-39 Sandefjord(Torp) yes ENTO TRF ENTO https://torp.no/ https://en.wikipedia.org/wiki/Sandefjord_Airport%2C_Torp Torp
77 2601 ENVA large_airport Trondheim Airport, Værnes 63.457802 10.924 56 EU NO NO-50 Trondheim yes ENVA TRD ENVA https://avinor.no/en/airport/trondheim-airport/ https://en.wikipedia.org/wiki/Trondheim_Airport%2C_V%C3%A6rnes
78 2602 ENZV large_airport Stavanger Airport, Sola 58.876701 5.63778 29 EU NO NO-11 Stavanger yes ENZV SVG ENZV http://www.avinor.no/en/airport/stavanger/ https://en.wikipedia.org/wiki/Stavanger_Airport,_Sola Sola Air Station, Stavanger lufthavn, Sola
79 2608 EPGD large_airport Gdańsk Lech Wałęsa Airport 54.377602 18.4662 489 EU PL PL-PM Gdańsk yes EPGD GDN EPGD http://www.airport.gdansk.pl/ https://en.wikipedia.org/wiki/Gda%C5%84sk_Lech_Wa%C5%82%C4%99sa_Airport Gdańsk-Rębiechowo
80 2609 EPKK large_airport Kraków John Paul II International Airport 50.077702 19.7848 791 EU PL PL-MA Balice yes EPKK KRK EPKK http://www.krakowairport.pl/en/ https://en.wikipedia.org/wiki/Kraków_John_Paul_II_International_Airport Krakov
81 2613 EPKT large_airport Katowice Wojciech Korfanty International Airport 50.476015 19.080705 995 EU PL PL-SL Katowice yes EPKT KTW EPKT http://katowice.airport.com https://en.wikipedia.org/wiki/Katowice_International_Airport Pyrzowice, Dąbrowa Górnicza, Katowice, Silesia, GOP, Silesia Metropolis, Gliwice, Piekary Śląskie, Bytom, Zabrze, Sosnowiec
82 309484 EPLB large_airport Lublin Airport 51.240157 22.713461 633 EU PL PL-LU Lublin yes EPLB LUZ EPLB https://en.wikipedia.org/wiki/Lublin_Airport
83 2616 EPLL large_airport Łódź Władysław Reymont Airport 51.721901 19.3981 604 EU PL PL-LD Łódź yes EPLL LCJ EPLL https://www.lodz-airport.pl/en https://en.wikipedia.org/wiki/%C5%81%C3%B3d%C5%BA_W%C5%82adys%C5%82aw_Reymont_Airport Lublinek
84 2625 EPPO large_airport Poznań-Ławica Airport 52.421598 16.823359 308 EU PL PL-WP Poznań yes EPPO POZ EPPO https://poznanairport.pl https://en.wikipedia.org/wiki/Pozna%C5%84-%C5%81awica_Airport Poznań–Ławica Henryk Wieniawski Airport
85 2629 EPRZ large_airport Rzeszów-Jasionka Airport 50.109791 22.024155 693 EU PL PL-PK Jasionka yes EPRZ RZE EPRZ https://www.rzeszowairport.pl https://en.wikipedia.org/wiki/Rzesz%C3%B3w-Jasionka_Airport
86 2630 EPSC large_airport Solidarity Szczecin–Goleniów Airport 53.584702 14.9022 154 EU PL PL-ZP Szczecin(Glewice) yes EPSC SZZ EPSC http://www.airport.com.pl/en/index.php https://en.wikipedia.org/wiki/Szczecin-Goleni%C3%B3w_%22Solidarno%C5%9B%C4%87%22_Airport Solidarność, Glewice, Port Lotniczy Szczecin–Goleniów im. NSZZ Solidarność
87 2637 EPWA large_airport Warsaw Chopin Airport 52.165699 20.9671 362 EU PL PL-MZ Warsaw yes EPWA WAW EPWA http://www.lotnisko-chopina.pl/?lang=en https://en.wikipedia.org/wiki/Warsaw_Frederic_Chopin_Airport Okęcie, Warszawa
88 2638 EPWR large_airport Copernicus Wrocław Airport 51.103719 16.882096 404 EU PL PL-DS Wrocław yes EPWR WRO EPWR http://www.airport.wroclaw.pl/en/index.php https://en.wikipedia.org/wiki/Copernicus_Airport_Wroc%C5%82aw Strachowice, Breslau, Wroclaw
89 2648 ESGG large_airport Göteborg Landvetter Airport 57.6628 12.2798 506 EU SE SE-Q Göteborg yes ESGG GOT ESGG https://www.swedavia.com/landvetter/ https://en.wikipedia.org/wiki/G%C3%B6teborg_Landvetter_Airport Göteborg-Landvetter, Gothenburg
90 2660 ESKN large_airport Stockholm Skavsta Airport 58.789656 16.911479 140 EU SE SE-D Nyköping yes ESKN NYO ESKN https://en.wikipedia.org/wiki/Stockholm-Skavsta_Airport
91 316498 ESKS large_airport Scandinavian Mountains Airport 61.16508 12.83352 1608 EU SE SE-W Malung-Sälen yes ESKS SCR ESKS https://scandinavianmountains.se/en/home/ https://en.wikipedia.org/wiki/Scandinavian_Mountains_Airport Sälens Flygplats, Trysil, Sälen Trysil Airport, Sälen/Scandinavian Mountains Airport
92 2669 ESMS large_airport Malmö Sturup Airport 55.535564 13.376327 236 EU SE SE-M Malmö yes ESMS MMX ESMS http://www.swedavia.com/malmo/ https://en.wikipedia.org/wiki/Malm%C3%B6_Airport
93 2685 ESNQ large_airport Kiruna Airport 67.821999 20.3368 1508 EU SE SE-BD Kiruna yes ESNQ KRN ESNQ https://www.swedavia.com/kiruna/ https://en.wikipedia.org/wiki/Kiruna_Airport
94 2689 ESNU large_airport Umeå Airport 63.791801 20.282801 24 EU SE SE-AC Umeå yes ESNU UME ESNU https://www.swedavia.com/umea/ https://en.wikipedia.org/wiki/Ume%C3%A5_Airport
95 2696 ESOW large_airport Stockholm Västerås Airport 59.589401 16.6336 21 EU SE SE-U Stockholm / Västerås yes ESOW VST ESOW https://en.wikipedia.org/wiki/Stockholm-V%C3%A4ster%C3%A5s_Airport
96 2697 ESPA large_airport Luleå Airport 65.5438 22.122 65 EU SE SE-BD Luleå yes ESPA LLA ESPA https://www.swedavia.com/lulea/ https://en.wikipedia.org/wiki/Lule%C3%A5_Airport Kallax Air Base
97 2701 ESSA large_airport Stockholm-Arlanda Airport 59.64849 17.928829 137 EU SE SE-AB Stockholm yes ESSA ARN ESSA http://www.swedavia.se/arlanda/ https://en.wikipedia.org/wiki/Stockholm-Arlanda_Airport
98 2706 ESSL large_airport Linköping City Airport 58.404909 15.684499 172 EU SE SE-E Linköping yes ESSL LPI ESSL http://www.linkopingsflygplats.se/ https://en.wikipedia.org/wiki/Link%C3%B6ping_SAAB_Airport
99 2710 ESSV large_airport Visby Airport 57.6628 18.346201 164 EU SE SE-I Visby yes ESSV VBY ESSV https://www.swedavia.com/visby/ https://en.wikipedia.org/wiki/Visby_Airport
100 2757 EVLA large_airport Liepāja International Airport 56.517502 21.096901 16 EU LV LV-112 Liepāja no EVLA LPX EVLA http://www.liepaja-airport.lv/ https://en.wikipedia.org/wiki/Liep%C4%81ja_International_Airport
101 2758 EVRA large_airport Riga International Airport 56.920752 23.970711 36 EU LV LV-062 Riga yes EVRA RIX EVRA http://www.riga-airport.com/ https://en.wikipedia.org/wiki/Riga_International_Airport
102 2760 EYKA large_airport Kaunas International Airport 54.963965 24.08582 256 EU LT LT-KU Kaunas yes EYKA KUN EYKA http://www.kaunasair.lt/index.php?lang=2&m=2&p=210 https://en.wikipedia.org/wiki/Kaunas_Airport Kauno tarptautinis Oro Uostas
103 2762 EYPA large_airport Palanga International Airport 55.973202 21.093901 33 EU LT LT-KL Palanga yes EYPA PLQ EYPA http://www.palanga-airport.lt/ https://en.wikipedia.org/wiki/Palanga_International_Airport
104 2766 EYVI large_airport Vilnius International Airport 54.634102 25.285801 648 EU LT LT-VL Vilnius yes EYVI VNO EYVI http://www.vilnius-airport.lt/en/ https://en.wikipedia.org/wiki/Vilnius_International_Airport
105 3972 LATI large_airport Tirana International Airport Mother Teresa 41.4147 19.7206 126 EU AL AL-02 Rinas yes LATI TIA LATI http://www.tirana-airport.com/ https://en.wikipedia.org/wiki/Tirana_International_Airport_N%C3%ABn%C3%AB_Tereza Rinas International Airport, Aeroporti Nënë Tereza
106 3974 LBBG large_airport Burgas Airport 42.569917 27.515173 135 EU BG BG-02 Burgas yes LBBG BOJ LBBG https://en.wikipedia.org/wiki/Burgas_Airport Bourgas
107 3976 LBPD large_airport Plovdiv International Airport 42.067799 24.8508 597 EU BG BG-16 Plovdiv yes LBPD PDV LBPD http://www.plovdivairport.com/ https://en.wikipedia.org/wiki/Plovdiv_International_Airport
108 3977 LBSF large_airport Sofia Airport 42.696357 23.417671 1742 EU BG BG-22 Sofia yes LBSF SOF LBSF http://sofia-airport.bg https://en.wikipedia.org/wiki/Sofia_Airport Sofia International Airport, Vrazhdebna Air Base
109 3979 LBWN large_airport Varna Airport 43.232101 27.8251 230 EU BG BG-03 Varna yes LBWN VAR LBWN https://en.wikipedia.org/wiki/Varna_Airport Aksakovo
110 3984 LDDU large_airport Dubrovnik Ruđer Bošković Airport 42.562247 18.265543 527 EU HR HR-19 Dubrovnik yes LDDU DBV LDDU https://www.airport-dubrovnik.hr/en https://en.wikipedia.org/wiki/Dubrovnik_Airport Močići Airport, Zračna luka Ruđer Bošković Dubrovnik
111 3987 LDPL large_airport Pula Airport 44.893501 13.9222 274 EU HR HR-18 Pula yes LDPL PUY LDPL https://airport-pula.hr/en/ https://en.wikipedia.org/wiki/Pula_Airport
112 3989 LDRI large_airport Rijeka Airport 45.216376 14.57085 278 EU HR HR-08 Rijeka(Omišalj) yes LDRI RJK LDRI https://www.rijeka-airport.hr/ https://en.wikipedia.org/wiki/Rijeka_Airport Reka, Rika, Fiume, Omišalj
113 3991 LDSP large_airport Split Saint Jerome Airport 43.538898 16.298 79 EU HR HR-17 Split yes LDSP SPU LDSP https://split-airport.hr https://en.wikipedia.org/wiki/Split_Airport
114 3993 LDZA large_airport Zagreb Franjo Tuđman International Airport 45.742901 16.0688 353 EU HR HR-01 Velika Gorica yes LDZA ZAG LDZA https://www.zagreb-airport.hr/en https://en.wikipedia.org/wiki/Zagreb_Airport Pleso
115 3994 LDZD large_airport Zadar Airport 44.096986 15.353565 289 EU HR HR-13 Zadar yes LDZD ZAD LDZD https://www.zadar-airport.hr/en https://en.wikipedia.org/wiki/Zadar_Airport
116 3997 LEAL large_airport Alicante-Elche Miguel Hernández Airport 38.2822 -0.558156 142 EU ES ES-VC Alicante yes LEAL ALC LEAL https://www.aena.es/en/alicante-elche.html https://en.wikipedia.org/wiki/Alicante–Elche_Miguel_Hernández_Airport
117 3999 LEAS large_airport Asturias Airport 43.563599 -6.03462 416 EU ES ES-AS Ranón yes LEAS OVD LEAS https://www.aena.es/en/asturias.html https://en.wikipedia.org/wiki/Asturias_Airport
118 4001 LEBB large_airport Bilbao Airport 43.301102 -2.91061 138 EU ES ES-PV Bilbao yes LEBB BIO LEBB https://www.aena.es/en/bilbao.html https://en.wikipedia.org/wiki/Bilbao_Airport
119 4004 LEBL large_airport Josep Tarradellas Barcelona-El Prat Airport 41.2971 2.07846 12 EU ES ES-CT Barcelona yes LEBL BCN LEBL https://www.aena.es/en/josep-tarradellas-barcelona-el-prat.html https://en.wikipedia.org/wiki/Barcelona%E2%80%93El_Prat_Airport El Prat Airport,Barcelona International,Josep Tarradellas Barcelona-El Prat Airport
120 4010 LEGE large_airport Girona-Costa Brava Airport 41.904639 2.761774 468 EU ES ES-CT Girona yes LEGE GRO LEGE https://www.aena.es/en/girona-costa-brava.html https://en.wikipedia.org/wiki/Girona-Costa_Brava_Airport Girona, Costa Brava, Girona-Costa Brava
121 4013 LEIB large_airport Ibiza Airport 38.872898 1.37312 24 EU ES ES-IB Ibiza (Eivissa) yes LEIB IBZ LEIB https://www.aena.es/en/ibiza.html https://en.wikipedia.org/wiki/Ibiza_Airport
122 4019 LEMD large_airport Adolfo Suárez Madrid–Barajas Airport 40.493407 -3.572249 1998 EU ES ES-MD Madrid yes LEMD MAD LEMD https://www.aena.es/en/adolfo-suarez-madrid-barajas.html https://en.wikipedia.org/wiki/Adolfo_Su%C3%A1rez_Madrid%E2%80%93Barajas_Airport Leganés, Madrid Barajas International Airport
123 4020 LEMG large_airport Málaga-Costa del Sol Airport 36.6749 -4.49911 53 EU ES ES-AN Málaga yes LEMG AGP LEMG https://www.aena.es/en/malaga-costa-del-sol.html https://en.wikipedia.org/wiki/M%C3%A1laga_Airport Costa del Sol, Malaga
124 4021 LEMH large_airport Menorca Airport 39.862598 4.21865 302 EU ES ES-IB Mahón (Maó) yes LEMH MAH LEMH https://www.aena.es/en/menorca.html https://en.wikipedia.org/wiki/Menorca_Airport Menorca
125 308134 LEMI large_airport Region of Murcia International Airport 37.802852 -1.124884 644 EU ES ES-MC Corvera yes LEMI RMU LEMI https://www.aena.es/en/internacional-region-de-murcia.html https://en.wikipedia.org/wiki/Murcia-Corvera_Airport
126 4035 LEPA large_airport Palma de Mallorca Airport 39.551701 2.73881 27 EU ES ES-IB Palma de Mallorca yes LEPA PMI LEPA https://www.aena.es/en/palma-de-mallorca.html https://en.wikipedia.org/wiki/Son_Sant_Joan_Airport Son Sant Joan Airport, LESJ
127 4031 LERS large_airport Reus Airport 41.147509 1.168354 233 EU ES ES-CT Reus yes LERS REU LERS https://www.aena.es/en/reus.html https://en.wikipedia.org/wiki/Reus_Airport
128 4038 LEST large_airport Santiago-Rosalía de Castro Airport 42.896301 -8.41514 1213 EU ES ES-GA Santiago de Compostela yes LEST SCQ LEST https://www.aena.es/en/santiago-rosalia-de-castro.html https://en.wikipedia.org/wiki/Santiago_de_Compostela_Airport
129 4042 LEVC large_airport Valencia Airport 39.489162 -0.480961 240 EU ES ES-VC Valencia yes LEVC VLC LEVC https://www.aena.es/en/valencia.html https://en.wikipedia.org/wiki/Valencia_Airport
130 4048 LEZG large_airport Zaragoza Airport 41.666199 -1.04155 863 EU ES ES-AR Zaragoza yes LEZG ZAZ LEZG https://www.aena.es/en/zaragoza.html https://en.wikipedia.org/wiki/Zaragoza_Airport Zaragoza Air Base
131 4049 LEZL large_airport Seville Airport 37.417999 -5.89311 112 EU ES ES-AN Seville yes LEZL SVQ LEZL https://www.aena.es/en/seville.html https://en.wikipedia.org/wiki/Seville_Airport Sevilla, Sevilla Airport
132 4060 LFBD large_airport Bordeaux–Mérignac Airport 44.82865 -0.715356 162 EU FR FR-NAQ Bordeaux yes LFBD BOD LFBD https://www.bordeaux.aeroport.fr/ https://en.wikipedia.org/wiki/Bordeaux%E2%80%93M%C3%A9rignac_Airport
133 4070 LFBO large_airport Toulouse-Blagnac Airport 43.629101 1.36382 499 EU FR FR-OCC Toulouse/Blagnac yes LFBO TLS LFBO http://www.toulouse.aeroport.fr/ https://en.wikipedia.org/wiki/Toulouse_Blagnac_International_Airport Airbus
134 4123 LFKB large_airport Bastia-Poretta International airport 42.5527 9.48373 26 EU FR FR-COR Bastia yes LFKB BIA LFKB https://bastia-aeroport.cci.corsica/ https://en.wikipedia.org/wiki/Bastia_-_Poretta_Airport
135 4125 LFKF large_airport Figari Sud-Corse Airport 41.50185 9.097092 85 EU FR FR-COR Figari yes LFKF FSC LFKF https://figari-aeroport.cci.corsica/ https://en.wikipedia.org/wiki/Figari_Sud-Corse_Airport
136 4132 LFLC large_airport Clermont-Ferrand Auvergne airport 45.786701 3.16917 1090 EU FR FR-ARA Clermont-Ferrand yes LFLC CFE LFLC https://www.clermont-aeroport.com/en https://en.wikipedia.org/wiki/Clermont-Ferrand_Auvergne_Airport
137 4137 LFLL large_airport Lyon Saint-Exupéry Airport 45.725996 5.090139 821 EU FR FR-ARA Colombier-Saugnieu, Rhône yes LFLL LYS LFLL https://www.lyonaeroports.com/ https://en.wikipedia.org/wiki/Lyon%E2%80%93Saint-Exup%C3%A9ry_Airport Lyon, Saint-Exupéry, Satolas
138 4155 LFML large_airport Marseille Provence Airport 43.438088 5.2125 74 EU FR FR-PAC Marignane, Bouches-du-Rhône yes LFML MRS LFML https://www.marseille.aeroport.fr/ https://en.wikipedia.org/wiki/Marseille_Provence_Airport
139 4156 LFMN large_airport Nice-Côte d'Azur Airport 43.658401 7.21587 12 EU FR FR-PAC Nice, Alpes-Maritimes yes LFMN NCE LFMN https://www.nice.aeroport.fr/en https://en.wikipedia.org/wiki/C%C3%B4te_d'Azur_International_Airport
140 4161 LFMT large_airport Montpellier-Méditerranée Airport 43.576199 3.96301 17 EU FR FR-OCC Montpellier/Méditerranée yes LFMT MPL LFMT https://www.montpellier.aeroport.fr/ https://en.wikipedia.org/wiki/Montpellier_-_M%C3%A9diterran%C3%A9e_Airport
141 4169 LFOB large_airport Beauvais-Tillé airport 49.454399 2.11278 359 EU FR FR-HDF Beauvais yes LFOB BVA LFOB https://www.aeroportparisbeauvais.com/ https://en.wikipedia.org/wiki/Paris_Beauvais_Till%C3%A9_Airport Beauvais-Tillé
142 4185 LFPG large_airport Charles de Gaulle International Airport 49.00896 2.554117 392 EU FR FR-IDF Paris (Roissy-en-France, Val-d'Oise) yes LFPG CDG LFPG http://www.aeroportsdeparis.fr/ https://en.wikipedia.org/wiki/Charles_de_Gaulle_Airport PAR, Aéroport Roissy-Charles de Gaulle, Roissy Airport
143 4202 LFQQ large_airport Lille Airport 50.566564 3.102429 157 EU FR FR-HDF Lesquin yes LFQQ LIL LFQQ https://www.lille.aeroport.fr/home/ https://en.wikipedia.org/wiki/Lille_Airport Aéroport de Lille. Lille-Lesquin Airport
144 4206 LFRB large_airport Brest Bretagne airport 48.447899 -4.41854 325 EU FR FR-BRE Brest yes LFRB BES LFRB https://www.brest.aeroport.bzh https://en.wikipedia.org/wiki/Brest_Bretagne_Airport
145 4221 LFRS large_airport Nantes Atlantique Airport 47.153198 -1.61073 90 EU FR FR-PDL Nantes yes LFRS NTE LFRS https://www.nantes.aeroport.fr/en https://en.wikipedia.org/wiki/Nantes_Atlantique_Airport
146 4226 LFSB large_airport EuroAirport Basel–Mulhouse–Freiburg 47.60068 7.521117 885 EU FR FR-GES Bâle / Mulhouse yes LFSB BSL LFSB http://www.euroairport.com/ https://en.wikipedia.org/wiki/EuroAirport_Basel-Mulhouse-Freiburg MLH, EAP, LSZM, Bâle, Basle
147 4238 LFST large_airport Strasbourg Airport 48.5383 7.62823 505 EU FR FR-GES Strasbourg yes LFST SXB LFST http://www.strasbourg.aeroport.fr/E/index.php https://en.wikipedia.org/wiki/Strasbourg_Airport BA 124
148 4251 LGAV large_airport Athens Eleftherios Venizelos International Airport 37.936401 23.9445 308 EU GR GR-I Spata-Artemida yes LGAV ATH LGAV https://www.aia.gr https://en.wikipedia.org/wiki/Athens_International_Airport
149 4258 LGIR large_airport Heraklion International Nikos Kazantzakis Airport 35.339699 25.1803 115 EU GR GR-M Heraklion yes LGIR HER LGIR https://www.heraklion-airport.gr/en/ https://en.wikipedia.org/wiki/Heraklion_International_Airport_"Nikos_Kazantzakis" Crete Island
150 4266 LGKR large_airport Corfu Ioannis Kapodistrias International Airport 39.60145 19.912179 6 EU GR GR-F Kerkyra (Corfu) yes LGKR CFU LGKR https://www.cfu-airport.gr/en https://en.wikipedia.org/wiki/Corfu_International_Airport Corfu
151 4268 LGKV large_airport Kavala Alexander the Great International Airport 40.9133 24.6192 18 EU GR GR-A Kavala yes LGKV KVA LGKV https://www.kva-airport.gr/en https://en.wikipedia.org/wiki/Kavala_International_Airport Megas Alexandros
152 4281 LGSA large_airport Chania International Airport 35.531207 24.150673 490 EU GR GR-M Souda yes LGSA CHQ LGSA https://en.wikipedia.org/wiki/Chania_International_Airport
153 4286 LGSR large_airport Santorini International Airport 36.400045 25.478638 127 EU GR GR-L Santorini Island yes LGSR JTR LGSR https://www.jtr-airport.gr/ https://en.wikipedia.org/wiki/Santorini_(Thira)_National_Airport
154 4293 LGTS large_airport Thessaloniki Macedonia International Airport 40.51928 22.970009 22 EU GR GR-B Thessaloniki yes LGTS SKG LGTS https://www.skg-airport.gr/en https://en.wikipedia.org/wiki/Thessaloniki_Airport Makedonia. Macedonia. Salonica
155 4296 LHBP large_airport Budapest Liszt Ferenc International Airport 47.43018 19.262393 495 EU HU HU-BU Budapest yes LHBP BUD LHBP http://www.bud.hu/english https://en.wikipedia.org/wiki/Budapest_Ferenc_Liszt_International_Airport Ferihegyi nemzetközi repülőtér, Budapest Liszt Ferenc international Airport
156 4297 LHDC large_airport Debrecen International Airport 47.489469 21.616278 359 EU HU HU-HB Debrecen yes LHDC DEB LHDC http://www.airportdebrecen.hu/ https://en.wikipedia.org/wiki/Debrecen_International_Airport Debreceni nemzetközi repülőtér
157 4302 LHPP large_airport Pécs-Pogány International Airport 45.988891 18.242044 651 EU HU HU-BA Pécs yes LHPP PEV LHPP http://www.airportpecs.hu/ https://en.wikipedia.org/wiki/P%C3%A9cs-Pog%C3%A1ny_International_Airport Pecs South Airport, QPJ
158 4310 LIBD large_airport Bari Karol Wojtyła International Airport 41.138901 16.760599 193 EU IT IT-75 Bari yes LIBD BRI LIBD BA02 http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=25 https://en.wikipedia.org/wiki/Bari_Karol_Wojty%C5%82a_Airport Bari "Karol Wojtyla" International Airport, Palese Macchie Airport, Palese Airport
159 4314 LIBP large_airport Abruzzo Airport 42.431079 14.182981 48 EU IT IT-65 Pescara yes LIBP PSR LIBP PE01 http://www.abruzzo-airport.it/eng/index.php https://en.wikipedia.org/wiki/Abruzzo_Airport Pescara, Abruzzo Pasquale Liberi International
160 4315 LIBR large_airport Brindisi Airport 40.6576 17.947001 47 EU IT IT-75 Brindisi yes LIBR BDS LIBR BR03 http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=29 https://en.wikipedia.org/wiki/Brindisi_Airport Brindisi Papola Casale Airport, Brindisi Salento Airport, Brindisi Casale Antonio Papolo Airport, Salento Airport
161 4317 LICA large_airport Lamezia Terme Sant'Eufemia International Airport 38.906214 16.246007 39 EU IT IT-78 Lamezia Terme (CZ) yes LICA SUF LICA CZ02 https://en.wikipedia.org/wiki/Lamezia_Terme_International_Airport
162 4318 LICC large_airport Catania-Fontanarossa Airport 37.466801 15.0664 39 EU IT IT-82 Catania yes LICC CTA LICC CT03 http://www.aeroporto.catania.it/ https://en.wikipedia.org/wiki/Catania-Fontanarossa_Airport Lanza Di Trabie, Catania-Fontanarossa Vincenzo Bellini
163 4321 LICJ large_airport Falcone–Borsellino Airport 38.175999 13.091 65 EU IT IT-82 Palermo yes LICJ PMO LICJ PA03 http://www.gesap.it/index.php https://en.wikipedia.org/wiki/Falcone%E2%80%93Borsellino_Airport Palermo Airport, Punta Raisi Airport
164 4332 LIEE large_airport Cagliari Elmas Airport 39.251499 9.05428 13 EU IT IT-88 Cagliari yes LIEE CAG LIEE CA07 http://www.cagliariairport.it/en/ https://en.wikipedia.org/wiki/Cagliari_Elmas_Airport Cagliari Airport,Elmas
165 4333 LIEO large_airport Olbia Costa Smeralda Airport 40.898953 9.518457 37 EU IT IT-88 Olbia (SS) yes LIEO OLB LIEO SS03 https://www.geasar.it/en https://en.wikipedia.org/wiki/Olbia_Costa_Smeralda_Airport
166 4340 LIMC large_airport Milan Malpensa International Airport 45.6306 8.72811 768 EU IT IT-25 Ferno (VA) yes LIMC MXP LIMC MI12 http://www.sea-aeroportimilano.it/en/malpensa/index.phtml https://en.wikipedia.org/wiki/Malpensa_Airport MIL, Silvio Berlusconi
167 4342 LIMF large_airport Turin Airport 45.200802 7.64963 989 EU IT IT-21 Caselle Torinese (TO) yes LIMF TRN LIMF TO11 http://www.aeroportoditorino.it/ https://en.wikipedia.org/wiki/Turin_International_Airport Torino-Caselle Airport, Sandro Pertini Airport, Turin-Caselle Airport
168 4344 LIMJ large_airport Genoa Cristoforo Colombo Airport 44.412039 8.840732 13 EU IT IT-42 Genova (GE) yes LIMJ GOA LIMJ GE01 http://www.aeroportodigenova.com/en/home/index.php https://en.wikipedia.org/wiki/Genoa_Cristoforo_Colombo_Airport Genova Sestri, Sestri Cristoforo Colombo Airport, Cristoforo Colombo Airport
169 4354 LIPE large_airport Bologna Guglielmo Marconi Airport 44.5354 11.2887 123 EU IT IT-45 Bologna yes LIPE BLQ LIPE BO08 http://www.bologna-airport.it/?LN=UK https://en.wikipedia.org/wiki/Bologna_Airport Guglielmo Marconi Airport, Borgo Panigale Airport
170 4361 LIPQ large_airport Trieste Airport 45.827862 13.466672 39 EU IT IT-36 Ronchi dei Legionari/Trieste yes LIPQ TRS LIPQ GO02 http://www.triesteairport.it https://en.wikipedia.org/wiki/Trieste_%E2%80%93_Friuli_Venezia_Giulia_Airport Ronchi Dei Legionari Airport, Trieste-Ronchi dei Legionari Airport
171 4362 LIPR large_airport Federico Fellini International Airport 44.020024 12.612198 40 EU IT IT-45 Rimini (RN) yes LIPR RMI LIPR RN01 http://www.riminiairport.com/english/ https://en.wikipedia.org/wiki/Federico_Fellini_International_Airport Rimini Miramare Airport, Rimini San Marino International Airport
172 4366 LIPX large_airport Verona Villafranca Valerio Catullo Airport 45.394955 10.887303 239 EU IT IT-34 Caselle (VR) yes LIPX VRN LIPX VR10 http://www.aeroportoverona.it/ https://en.wikipedia.org/wiki/Verona_Airport Valerio Catullo, Villafranca International Airport, Valerio Catullo Airport, Villafranca Airport
173 4368 LIPZ large_airport Venice Marco Polo Airport 45.505299 12.3519 7 EU IT IT-34 Venezia (VE) yes LIPZ VCE LIPZ VE05 http://www.veniceairport.com/core/index.jsp?_requestid=37300&language=en https://en.wikipedia.org/wiki/Venice_Marco_Polo_Airport Venezia-Tessera, Venice-Tessera
174 4372 LIRF large_airport Rome–Fiumicino Leonardo da Vinci International Airport 41.804532 12.251998 13 EU IT IT-62 Rome yes LIRF FCO LIRF RM11 http://www.adr.it/portal/portal/adr/Fiumicino/Leonardo_da_vinci/Header_Window?action=2 https://en.wikipedia.org/wiki/Rome_Fiumicino_Airport ROM, Rome Fiumicino Airport, Fiumicino Airport
175 4378 LIRN large_airport Naples International Airport 40.886002 14.2908 294 EU IT IT-72 Napoli yes LIRN NAP LIRN NA01 http://www.portal.gesac.it/ https://en.wikipedia.org/wiki/Naples_International_Airport Capodichino Airport, Aeroporto Internazionale di Napoli-Capodichino Ugo Niutta, Ariopuorto 'nternazziunale 'e Napule-Capodichino Ugo Niutta
176 4379 LIRP large_airport Pisa International Airport 43.683899 10.3927 6 EU IT IT-52 Pisa (PI) yes LIRP PSA LIRP PI05 http://www.pisa-airport.com/ https://en.wikipedia.org/wiki/Pisa_International_Airport Galileo Galilei Airport, San Giusto Airport
177 4380 LIRQ large_airport Florence Airport, Peretola 43.808558 11.202822 142 EU IT IT-52 Firenze (FI) yes LIRQ FLR LIRQ FI04 http://www.aeroporto.firenze.it/ https://en.wikipedia.org/wiki/Florence_Airport Firenze, Amerigo Vespucci Airport, Aeroporto di Firenze-Peretola
178 4384 LIRZ large_airport Perugia San Francesco d'Assisi – Umbria International Airport 43.095901 12.5132 697 EU IT IT-55 Perugia (PG) yes LIRZ PEG LIRZ PG10 http://www.airport.umbria.it/ https://en.wikipedia.org/wiki/Perugia_San_Francesco_d%27Assisi_%E2%80%93_Umbria_International_Airport Perugia San Francesco d'Assisi, Perugia Sant'Egidio Airport, Umbria International Airport
179 4386 LJLJ large_airport Ljubljana Jože Pučnik Airport 46.223701 14.4576 1273 EU SI SI-012 Zgornji Brnik yes LJLJ LJU LJLJ http://www.lju-airport.si/ https://en.wikipedia.org/wiki/Ljubljana_Jo%C5%BEe_Pu%C4%8Dnik_Airport Ljubljana, Kranj
180 4393 LKCS large_airport České Budějovice South Bohemian Airport 48.948171 14.42832 1417 EU CZ CZ-JC České Budějovice yes LKCS JCL LKCS https://www.airport-cb.cz/ https://en.wikipedia.org/wiki/%C4%8Cesk%C3%A9_Bud%C4%9Bjovice_Airport Jihočeské letiště České Budějovice
181 4399 LKKV large_airport Karlovy Vary Airport 50.202999 12.915 1989 EU CZ CZ-KA Karlovy Vary yes LKKV KLV LKKV http://www.airport-k-vary.cz/indexEN.php https://en.wikipedia.org/wiki/Karlovy_Vary_Airport Karlovy Vary International
182 4402 LKMT large_airport Leoš Janáček Airport Ostrava 49.696301 18.111099 844 EU CZ CZ-MO Mošnov yes LKMT OSR LKMT http://www.airport-ostrava.cz/en/ https://en.wikipedia.org/wiki/Ostrava_Leos_Janacek_Airport Ostrava-Mošnov International Airport
183 4405 LKPD large_airport Pardubice Airport 50.015049 15.73981 741 EU CZ CZ-PA Pardubice yes LKPD PED LKPD http://www.airport-pardubice.cz/?lang=en https://en.wikipedia.org/wiki/Pardubice_Airport Letiště Pardubice
184 4408 LKPR large_airport Václav Havel Airport Prague 50.100874 14.259911 1247 EU CZ CZ-PR Prague yes LKPR PRG LKPR http://www.prg.aero https://en.wikipedia.org/wiki/Václav_Havel_Airport_Prague Letiště Praha-Ruzyně, Ruzyně International Airport, Letiště Václava Havla Praha
185 4427 LMML large_airport Malta International Airport 35.845932 14.491546 300 EU MT MT-25 Valletta yes LMML MLA LMML https://www.maltairport.com/ https://en.wikipedia.org/wiki/Malta_International_Airport Luqa Airport, Valletta, Gudja, RAF Luqa
186 4430 LOWG large_airport Graz Airport 46.9911 15.4396 1115 EU AT AT-6 Feldkirchen bei Graz yes LOWG GRZ LOWG http://www.flughafen-graz.at/ https://en.wikipedia.org/wiki/Graz_Airport Thalerhof Airport, Fliegerhorst Nittner, Nittner Air Base, Christophorus 12
187 4431 LOWI large_airport Innsbruck Airport 47.260201 11.344 1907 EU AT AT-7 Innsbruck yes LOWI INN LOWI http://www.innsbruck-airport.com/ https://en.wikipedia.org/wiki/Innsbruck_Airport Kranebitten Airport
188 4435 LOWK large_airport Klagenfurt Airport 46.642502 14.3377 1472 EU AT AT-2 Klagenfurt am Wörthersee yes LOWK KLU LOWK http://www.klagenfurt-airport.com/ https://en.wikipedia.org/wiki/Klagenfurt_Airport Kärnten Airport, Christophorus 11
189 4432 LOWL large_airport Linz-Hörsching Airport 48.235362 14.188128 980 EU AT AT-4 Linz yes LOWL LNZ LOWL http://www.flughafen-linz.at/ https://en.wikipedia.org/wiki/Linz_Airport Blue Danube Airport, Vogler Air Base, Fliegerhorst Vogler, Christophorus 10
190 4433 LOWS large_airport Salzburg Airport 47.793301 13.0043 1411 EU AT AT-5 Salzburg yes LOWS SZG LOWS http://www.salzburg-airport.com/ https://en.wikipedia.org/wiki/Salzburg_Airport Christophorus 6
191 4434 LOWW large_airport Vienna International Airport 48.110298 16.5697 600 EU AT AT-9 Vienna yes LOWW VIE LOWW http://www.viennaairport.com/en/ https://en.wikipedia.org/wiki/Vienna_International_Airport
192 4448 LPFR large_airport Faro - Gago Coutinho International Airport 37.015909 -7.970939 24 EU PT PT-08 Faro yes LPFR FAO LPFR http://faroairport.pt https://en.wikipedia.org/wiki/Faro_Airport_(Portugal)
193 4452 LPMA large_airport Cristiano Ronaldo International Airport 32.697812 -16.774613 192 EU PT PT-30 Funchal yes LPMA FNC LPMA http://www.aeroportomadeira.pt https://en.wikipedia.org/wiki/Madeira_Airport Madeira, Funchal
194 4456 LPPD large_airport João Paulo II Airport 37.7411994934 -25.6979007721 259 EU PT PT-20 Ponta Delgada yes LPPD PDL LPPD http://www.ana.pt/en-US/Aeroportos/acores/PontaDelgada/Pages/Homepage-PontaDelgada.aspx https://en.wikipedia.org/wiki/Jo%C3%A3o_Paulo_II_Airport
195 4459 LPPR large_airport Francisco de Sá Carneiro Airport 41.2481002808 -8.68138980865 228 EU PT PT-13 Porto yes LPPR OPO LPPR http://www.ana.pt/en-US/Aeroportos/porto/Porto/Pages/Homepage-Porto.aspx https://en.wikipedia.org/wiki/Francisco_S%C3%A1_Carneiro_Airport
196 4461 LPPT large_airport Lisbon Humberto Delgado Airport 38.7813 -9.13592 374 EU PT PT-11 Lisbon yes LPPT LIS LPPT https://lisbonairport.pt https://en.wikipedia.org/wiki/Lisbon_Airport Lisboa, Portela
197 4467 LQBK large_airport Banja Luka International Airport 44.941399 17.297501 400 EU BA BA-SRP Mahovljani yes LQBK BNX LQBK http://www.banjaluka-airport.com/ https://en.wikipedia.org/wiki/Banja_Luka_International_Airport Mahovljani Airport
198 4468 LQMO large_airport Mostar International Airport 43.282489 17.846101 156 EU BA BA-BIH Mostar yes LQMO OMO LQMO http://www.mostar-airport.ba/ https://en.wikipedia.org/wiki/Mostar_International_Airport
199 4469 LQSA large_airport Sarajevo International Airport 43.8246 18.331499 1708 EU BA BA-BIH Sarajevo yes LQSA SJJ LQSA http://www.sarajevo-airport.ba/ https://en.wikipedia.org/wiki/Sarajevo_International_Airport
200 4470 LQTZ large_airport Tuzla International Airport 44.459858 18.723627 784 EU BA BA-BIH Dubrave Gornje yes LQTZ TZL LQTZ http://www.tuzla-airport.ba/ https://en.wikipedia.org/wiki/Tuzla_International_Airport
201 4472 LRBC large_airport Bacău George Enescu International Airport 46.5219 26.910299 607 EU RO RO-BC Bacău yes LRBC BCM LRBC http://www.bacauairport.ro/ https://en.wikipedia.org/wiki/Bac%C4%83u_International_Airport Bacau, RoAF 95th Air Base
202 333670 LRBV large_airport Brașov-Ghimbav International Airport 45.70558 25.522871 1740 EU RO RO-BV Brașov (Ghimbav) yes LRBV GHV LRBV https://brasovairport.ro/ https://en.wikipedia.org/wiki/Brașov-Ghimbav_International_Airport Aeroportul Internațional Brașov-Ghimbav
203 4475 LRCK large_airport Mihail Kogălniceanu International Airport 44.362202 28.4883 353 EU RO RO-CT Constanța yes LRCK CND LRCK http://www.mk-airport.ro/ https://en.wikipedia.org/wiki/Mihail_Kog%C4%83lniceanu_International_Airport Constanța, Mihail Kogalniceanu, RoAF 57th Air Base
204 4476 LRCL large_airport Avram Iancu Cluj International Airport 46.786042 23.685733 1039 EU RO RO-CJ Cluj-Napoca yes LRCL CLJ LRCL http://www.airportcluj.ro/ https://en.wikipedia.org/wiki/Cluj_International_Airport Someşeni Airport
205 4479 LRCV large_airport Craiova International Airport 44.3181 23.888599 626 EU RO RO-DJ Craiova yes LRCV CRA LRCV http://www.aeroportcraiova.ro/ https://en.wikipedia.org/wiki/Craiova_International_Airport
206 4480 LRIA large_airport Iaşi International Airport 47.179633 27.621431 411 EU RO RO-IS Iaşi yes LRIA IAS LRIA https://www.aeroport-iasi.ro/ https://en.wikipedia.org/wiki/Ia%C5%9Fi_International_Airport Iasi, Jassy
207 4481 LROD large_airport Oradea International Airport 47.025299 21.9025 465 EU RO RO-BH Oradea yes LROD OMR LROD https://www.aeroportoradea.ro/ https://en.wikipedia.org/wiki/Oradea_International_Airport
208 4482 LROP large_airport Bucharest Henri Coandă International Airport 44.571792 26.103285 314 EU RO RO-IF Otopeni yes LROP OTP LROP http://www.bucharestairports.ro/en/ https://en.wikipedia.org/wiki/Henri_Coand%C4%83_International_Airport BUH, Otopeni Airport, RoAF 90th Airlift Base
209 4483 LRSB large_airport Sibiu International Airport 45.785841 24.086666 1496 EU RO RO-SB Sibiu yes LRSB SBZ LRSB http://www.sibiuairport.ro/ https://en.wikipedia.org/wiki/Sibiu_International_Airport
210 4485 LRSV large_airport Suceava Ștefan cel Mare International Airport 47.6875 26.354099 1375 EU RO RO-SV Suceava yes LRSV SCV LRSV http://www.aeroportsuceava.ro/index_en.html https://en.wikipedia.org/wiki/Suceava_Airport
211 4488 LRTR large_airport Timișoara Traian Vuia International Airport 45.809898 21.3379 348 EU RO RO-TM Timişoara yes LRTR TSR LRTR http://www.aerotim.ro/ https://en.wikipedia.org/wiki/Traian_Vuia_International_Airport RoAF 93rd Air Base, Giarmata Airport, Temesvár, Temeschburg, Temeswar, Temeschwar, Тимишоара, Темишвар, Temišvar, Banat
212 4490 LSGG large_airport Geneva International Airport 46.238098 6.10895 1411 EU CH CH-GE Geneva yes LSGG GVA LSGG https://www.gva.ch/ https://en.wikipedia.org/wiki/Geneva_Airport Cointrin Airport
213 4505 LSZH large_airport Zürich Airport 47.458056 8.548056 1417 EU CH CH-ZH Zurich yes LSZH ZRH LSZH http://www.zurich-airport.com/ https://en.wikipedia.org/wiki/Zurich_Airport
214 317457 LTFM large_airport İstanbul Airport 41.274874 28.732136 325 EU TR TR-34 Istanbul yes LTFM IST LTFM https://www.istairport.com/ https://en.wikipedia.org/wiki/Istanbul_Airport Arnavutköy
215 4571 LUKK large_airport Chişinău International Airport 46.92774 28.931704 399 EU MD MD-CU Chişinău yes LUKK RMO LUKK http://www.airport.md/ https://en.wikipedia.org/wiki/Chi%C5%9Fin%C4%83u_International_Airport KIV
216 4572 LWOH large_airport Ohrid St. Paul the Apostle Airport 41.18 20.7423 2313 EU MK MK-003 Ohrid yes LWOH OHD LWOH https://ohd.airports.com.mk/ https://en.wikipedia.org/wiki/Ohrid_Airport
217 4573 LWSK large_airport Skopje International Airport 41.958087 21.622581 781 EU MK MK-008 Ilinden yes LWSK SKP LWSK http://skp.airports.com.mk/ https://en.wikipedia.org/wiki/Skopje_Airport Petrovec
218 4574 LXGB large_airport Gibraltar Airport 36.151679 -5.34978 12 EU GI GI-U-A Gibraltar yes LXGB GIB LXGB https://www.gibraltarairport.gi/ https://en.wikipedia.org/wiki/Gibraltar_Airport
219 4610 LYBE large_airport Belgrade Nikola Tesla Airport 44.818401 20.309099 335 EU RS RS-00 Belgrade yes LYBE BEG LYBE http://www.beg.aero/ https://en.wikipedia.org/wiki/Belgrade_Nikola_Tesla_Airport Beograd, Surčin Airport
220 4612 LYNI large_airport Niš Constantine the Great Airport 43.336538 21.856242 648 EU RS RS-20 Niš yes LYNI INI LYNI https://ini.aerodromisrbije.rs/en/ https://en.wikipedia.org/wiki/Ni%C5%A1_Constantine_the_Great_Airport Aerodrom Konstantin Veliki Niš
221 4613 LYPG large_airport Podgorica Airport / Podgorica Golubovci Airbase 42.359402 19.2519 141 EU ME ME-16 Podgorica yes LYPG TGD LYPG https://montenegroairports.com/en/podgorica-airport/ https://en.wikipedia.org/wiki/Podgorica_Airport Podgorica Airbase, Golubovci Airbase, Аеродром Подгорица, Aerodrom Podgorica
222 4617 LZIB large_airport M. R. Štefánik Airport 48.1702 17.2127 436 EU SK SK-BL Bratislava yes LZIB BTS LZIB https://en.wikipedia.org/wiki/M._R._%C5%A0tef%C3%A1nik_Airport
223 6467 UKBB large_airport Boryspil International Airport 50.345001 30.894699 427 EU UA UA-32 Boryspil no UKBB KBP UKBB https://kbp.aero/en/ https://en.wikipedia.org/wiki/Boryspil_International_Airport Borispol, Міжнародний аеропорт "Бориспіль", Kyiv
224 6474 UKFF large_airport Simferopol International Airport 45.0522 33.975101 639 EU UA UA-43 Simferopol no UKFF SIP UKFF URFF https://new.sipaero.ru/ https://en.wikipedia.org/wiki/Simferopol_International_Airport
225 6481 UKLL large_airport Lviv International Airport 49.8125 23.9561 1071 EU UA UA-46 Lviv no UKLL LWO UKLL http://www.airport.lviv.ua/index.php?id=home&L=1 https://en.wikipedia.org/wiki/Lviv_International_Airport Міжнародний аеропорт "Львів"
226 6487 UKOO large_airport Odesa International Airport 46.427196 30.672649 172 EU UA UA-51 Odesa no UKOO ODS https://www.airport.od.ua/ https://en.wikipedia.org/wiki/Odessa_International_Airport ODS, UKOO, УКОО, Odesa Central, Міжнародний аеропорт Одеса, Odessa Airport
227 6489 ULLI large_airport Pulkovo Airport 59.800301 30.262501 78 EU RU RU-SPE St. Petersburg yes ULLI LED ULLI https://pulkovoairport.ru/ https://en.wikipedia.org/wiki/Pulkovo_Airport Аэропо́рт Пу́лково, Leningrad, Shosseynaya Airport, Saint Petersburg, Petrograd
228 6490 ULMM large_airport Emperor Nicholas II Murmansk Airport 68.7817 32.750801 266 EU RU RU-MUR Murmansk yes ULMM MMK ULMM http://www.airport-murmansk.ru/ https://en.wikipedia.org/wiki/Murmansk_Airport Аэропо́рт Му́рманска им. Николая II or Аэропорт Мурмаши, Мурманск
229 6495 UMBB large_airport Brest International Airport 52.108138 23.89676 468 EU BY BY-BR Brest yes UMBB BQT UMBB https://www.ban.by/filialy/brest https://en.wikipedia.org/wiki/Brest_Airport Аэропорт Брест
230 6498 UMKK large_airport Khrabrovo Airport 54.89161 20.598559 42 EU RU RU-KGD Kaliningrad yes UMKK KGD UMKK https://kgdavia.ru/ https://en.wikipedia.org/wiki/Khrabrovo_Airport УМКК, Храброво
231 6501 UMMS large_airport Minsk National Airport 53.888071 28.039964 670 EU BY BY-MI Minsk yes UMMS MSQ UMMS http://airport.by/en https://en.wikipedia.org/wiki/Minsk_National_Airport Нацыянальны аэрапорт, Minsk 2 Airport
232 6507 UNNT large_airport Novosibirsk Tolmachevo Airport 55.019756 82.618675 365 EU RU RU-NVS Novosibirsk yes UNNT OVB UNNT http://eng.tolmachevo.ru/ https://en.wikipedia.org/wiki/Tolmachevo_Airport
233 6512 URKK large_airport Krasnodar Pashkovsky International Airport 45.034465 39.174215 118 EU RU RU-KDA Krasnodar yes URKK KRR URKK https://krr.aero/en/ https://en.wikipedia.org/wiki/Pashkovsky_Airport Pashkovskiy Airport, Pashkovsky Airport
234 6514 URMM large_airport Mineralnye Vody Airport 44.225101 43.081902 1054 EU RU RU-STA Mineralnyye Vody yes URMM MRV URMM https://en.wikipedia.org/wiki/Mineralnye_Vody_Airport
235 326363 URRP large_airport Platov International Airport 47.493888 39.924722 213 EU RU RU-ROS Rostov-on-Don no URRP ROV URRP https://en.wikipedia.org/wiki/Platov_International_Airport УРРП, Международный аэропорт Платов
236 6519 URSS large_airport Sochi International Airport 43.449902 39.9566 89 EU RU RU-KDA Sochi yes URSS AER URSS https://aer.aero/en https://en.wikipedia.org/wiki/Sochi_International_Airport Adler Airport, Black Sea, Международный Аэропорт Сочи
237 6522 URWW large_airport Volgograd International Airport 48.78134 44.339194 482 EU RU RU-VGG Volgograd yes URWW VOG URWW https://en.wikipedia.org/wiki/Volgograd_International_Airport Gumrak Airport, Аэропорт Волгоград, Аэропорт Гумрак
238 26395 UUDL large_airport Golden Ring Yaroslavl International Airport 57.560699 40.157398 287 EU RU RU-YAR Tunoshna yes UUDL IAR UUDL https://en.wikipedia.org/wiki/Tunoshna
239 26396 UUEE large_airport Sheremetyevo International Airport 55.976858 37.41121 622 EU RU RU-MOS Moscow yes UUEE SVO UUEE http://svo.aero/ https://en.wikipedia.org/wiki/Sheremetyevo_International_Airport УУЕЕ, Москва, MOW, Международный аэропорт Шереметьево, Alexander S. Pushkin
240 26403 UWGG large_airport Nizhny Novgorod / Strigino International Airport 56.227351 43.785152 256 EU RU RU-NIZ Nizhny Novgorod yes UWGG GOJ UWGG http://www.airportnn.ru/en/ https://en.wikipedia.org/wiki/Nizhny_Novgorod_International_Airport УВГГ, ЬВГГ, Нижний Новгород / Стригино
241 42965 UWPS large_airport Saransk International Airport 54.12513 45.212257 676 EU RU RU-MO Saransk yes UWPS SKX UWPS https://en.wikipedia.org/wiki/Saransk_Airport Аэропорт Саранск
242 26411 UWUU large_airport Ufa International Airport 54.557499 55.874401 449 EU RU RU-BA Ufa yes UWUU UFA UWUU http://www.airportufa.ru/en/ https://en.wikipedia.org/wiki/Ufa_International_Airport УВУУ, Международный аэропорт Уфа
243 26412 UWWW large_airport Kurumoch International Airport 53.504902 50.164299 477 EU RU RU-SAM Samara yes UWWW KUF UWWW https://kuf.aero/en/ https://en.wikipedia.org/wiki/Kurumoch_International_Airport УВВВ, Самара / Курумоч
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ services:
volumes:
- "./public:/app/public"
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
# Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
- "./db:/app/db"
environment:
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
+76 -60
View File
@@ -1,21 +1,24 @@
import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// 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 → fly at 1000× →
// via the right pane → click its destination in the on-board list (left pane since
// v0.20.0, ISS-021) → fly at 1000× →
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
// penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model a full tank covers the
// round trip, but the return leaves too little for another outbound
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
@@ -52,8 +55,16 @@ async function getState(page: Page): Promise<StateBody> {
}
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
// weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
// several packages onto one plane stay under its payload deterministically.
async function createPackage(
page: Page,
from?: string,
to?: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } });
expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number];
}
@@ -63,10 +74,20 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
expect(res.ok()).toBeTruthy();
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
// resume it or the clock stays frozen and the next wait hangs.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -100,7 +121,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before the steps below act on the starter, so the
// exact-fuel pins never see a worn or broken plane (broken planes burn ×1.5 more).
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -117,6 +142,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
@@ -135,7 +163,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights + refuel
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights
// Self-heal: the starter plane may be anywhere from a previous run/spec.
await flyHomeViaApi(page);
const before = await getState(page);
@@ -167,8 +195,12 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
// package shows as loaded on the starter in the on-board section.
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
// FLY via the UI: click the destination name button in the on-board list.
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
@@ -205,23 +237,27 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
// wind-adjusted (v0.36.0) by departure weather vs route heading.
const entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
fuelAfter(
before.planes.find((p) => p.id === planeId)!.fuel,
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
exactKm,
),
6,
);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy();
s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
// Restore: fly home (repositioning — no package needed in the v0.12.0 model).
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
await setScale(page, SCALE);
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
@@ -232,9 +268,10 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
await flyHomeViaApi(page);
const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
// Two packages at the hub — load both, abandon one through the UI. Forced weights keep
// the pair under the starter's payload (v0.27.0) so both loads are deterministic.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
@@ -274,8 +311,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
// still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0)
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
@@ -308,15 +345,17 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home (scale is still SCALE from the expiry wait above).
await page.request.post('/api/planes/plane-starter/refuel');
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
});
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
test.setTimeout(600_000); // the FAR round trip alone is ~30 s real time at 1000×
test.setTimeout(300_000); // self-heal + one short hop each way at scale 1000
await flyHomeViaApi(page);
// Load a package whose origin the plane is not at → 409.
@@ -347,40 +386,17 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409);
// Fly home from the hop so the fuel section starts at the hub.
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
// Insufficient fuel (v0.16.0 weighted burn): a full tank covers the FAR round trip —
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
if (starter.fuel < starter.maxFuel) {
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long legs
}
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(farRes.ok()).toBeTruthy();
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
const sFar = await getState(page);
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR);
// The return leg fits: the weighted burn over it is less than what's left in the tank.
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
// …but the tank is now too light for another FAR leg.
const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(againFar.status()).toBe(409);
expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel (the plane is already home).
await page.request.post('/api/planes/plane-starter/refuel');
// ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
// auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
// insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
// app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
});
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
+5 -2
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version);
});
test('GET /api/openapi serves the spec with all ten endpoints and the package.json version', async ({ page }) => {
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
@@ -26,9 +26,12 @@ test('GET /api/openapi serves the spec with all ten endpoints and the package.js
'/api/market/buy-plane',
'/api/market/sell-plane',
'/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}/refuel',
'/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
+316 -10
View File
@@ -6,11 +6,17 @@ import { test, expect, type Page } from '@playwright/test';
// highlight layers (planes-highlight / routes-highlight / airports-highlight), each filtered
// to match nothing until a hover sets the filter — asserted here via window.__flightMap's
// queryRenderedFeatures, same pattern as e2e/map.spec.ts and e2e/map-interactions.spec.ts.
// ISS-022/025/027 (v0.22.0): any hover carrying two endpoints ALSO draws the full great-circle
// trace between them in a fourth top-most layer (`hover-route`, dashed) — job-list rows, on-board
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
const DEST = 'LEMD';
// Amsterdam — a third airport, distinct from hub and destination (the queued stop in the ISS-077 test).
const QUEUE = 'EHAM';
interface PlaneState {
id: string;
@@ -20,6 +26,8 @@ interface PlaneState {
maxFuel: number;
locationIcao: string;
destIcao: string | null;
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
lon: number;
}
interface PackageState {
@@ -49,9 +57,10 @@ async function postSim(page: Page, body: unknown) {
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -72,6 +81,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
@@ -89,6 +106,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
@@ -110,12 +130,30 @@ async function waitForAirportCount(page: Page): Promise<void> {
}
// Rendered features of the given layers in the current viewport, [] until the map exists.
function renderedFeatures(page: Page, layers: string[]) {
function renderedFeatures(
page: Page,
layers: string[],
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap;
if (!m) return [];
try {
return m.queryRenderedFeatures(undefined, { layers: ls });
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props,
// so geometry silently vanishes across the page boundary (CI runs #69–#71: strict-equality
// timeouts → TypeError on f.geometry.coordinates → filter-found-nothing timeouts). Map to
// plain objects IN-PAGE so the getter is evaluated before serialization.
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
type?: unknown;
id?: unknown;
properties?: unknown;
geometry?: unknown;
}>).map((f) => ({
type: f.type,
id: f.id,
properties: f.properties,
geometry: f.geometry,
}));
} catch {
return [];
}
@@ -157,6 +195,56 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
await page.waitForTimeout(300);
}
// [lon, lat] of an airport from the same /api/airports dataset the map draws its traces from.
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat];
}
// ISS-022/025/027: the hover-route layer must hold the great-circle trace from origin to dest.
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses (the 1420 km EDDF→LEMD leg is
// 4 segments at z=4 / 5 at z=5) with coordinates quantized to the rendered zoom's grid — up to ~half
// a grid step off (~0.001° observed at z≥4, ~0.02° worst case at z=0). A strict single-feature +
// exact-coords comparison can therefore never match even when the line is drawn correctly (CI run #69:
// all three hover-route timeouts while the trace was visibly rendered), so we accept any number of
// segments and require some segment to START within EPS of `from` and some to END within EPS of `to`.
// The true endpoints survive clipping verbatim in whichever tile contains them. (CI runs #69–#71:
// features could arrive WITHOUT a usable geometry because Playwright's evaluate serialization drops
// the lazy prototype getter — not MapLibre artifacts; renderedFeatures now maps to plain objects
// in-page.) The helper still filters to real LineString segments and treats "none yet" as keep-polling
// instead of throwing inside the poll callback. Call fitToAirports([from, to]) first — rendered
// features are viewport-scoped.
const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
// guards against degenerate/zero-length clipped segments and keeps this poll callback total — a
// throw inside expect.poll fails the test instantly instead of retrying. "None yet" → keep polling.
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The line may be clipped into several per-tile segments — check the endpoints across all of them.
const startsAtFrom = segs.some((f) => near(f.geometry.coordinates[0], from));
const endsAtTo = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], to);
});
return startsAtFrom && endsAtTo;
}, { timeout: 15_000, message: `waiting for the hover-route line ${fromIcao} → ${toIcao}` }).toBe(true);
}
test('hovering a fleet row highlights the plane on the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF, inside the initial view
@@ -227,6 +315,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0);
await row.hover();
await expect.poll(async () => {
@@ -235,20 +324,137 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}, { timeout: 15_000, message: 'waiting for the airports-highlight layer to show both endpoints' })
.toBe(JSON.stringify([HUB, DEST].sort()));
// ISS-022 (v0.22.0): the ENTIRE trace is drawn too — a great-circle line from origin to dest,
// not just the endpoint rings.
await expectHoverRoute(page, HUB, DEST);
// And the TARGET airport gets its own distinct-colored ring — only the destination, never the origin.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the destination' })
.toBe(JSON.stringify([DEST]));
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the target ring to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// Load a package onto the grounded starter so it shows up in the On board section.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
await row.hover();
// ISS-025 (v0.22.0): same full origin→dest trace as job-list rows…
await expectHoverRoute(page, HUB, DEST);
// …and the package's plane keeps its halo lit (the hover carries planeId).
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['planes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return feats.length > 0 && feats[0].properties.id === 'plane-starter';
}, { timeout: 15_000, message: 'waiting for the planes-highlight layer to show plane-starter' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
// Leave the instance clean: abandon the loaded package so later specs start with an empty starter.
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(200);
});
test('hovering the Relocate section previews the planned route and target airport', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
await page.goto('/');
await waitForAirportCount(page);
const section = page.getByTestId('relocate-section');
await expect(section, 'the Relocate section is visible').toBeVisible({ timeout: 15_000 });
// LeftPane fetches /api/airports on its own (separate from the map header count) — wait for
// the destination options to populate before selecting.
await expect(page.getByTestId('relocate-dest').locator(`option[value="${DEST}"]`)).toHaveCount(1, { timeout: 15_000 });
// Pick the starter + a known destination explicitly (the effective-selection defaults already do this).
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await page.getByTestId('relocate-dest').selectOption(DEST);
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['hover-route'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0);
// ISS-027 (v0.22.0): hovering the panel with a plane + destination selected previews the flight —
// dashed trace from the plane's airport to the destination…
await section.hover();
await expectHoverRoute(page, HUB, DEST);
// …and rings ONLY the target airport (the origin stays unmarked).
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-highlight layer to ring only the target' })
.toBe(JSON.stringify([DEST]));
// …plus the destination's own distinct-colored ring (ISS-022) — same single icao.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the target' })
.toBe(JSON.stringify([DEST]));
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the target ring to clear after mouseleave',
}).toBe(0);
});
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
await page.goto('/');
await waitForAirportCount(page);
@@ -261,8 +467,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
const block = page.getByTestId('onboard-plane-starter');
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
@@ -291,6 +497,106 @@ test('hovering an on-board block of a flying plane highlights its route', async
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
});
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
await page.goto('/');
await waitForAirportCount(page);
// ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's
// destination — load it, queue EHAM while grounded (pre-built routes are allowed), then start the
// HUB→DEST leg via the API (same pattern as e2e/map-interactions.spec.ts). The hover must trace
// from DEST (where this flight lands) to EHAM — NOT from the plane's live mid-flight position.
const pkg = await createPackage(page, HUB, QUEUE);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(queueRes.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
const flyBody = (await fly.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
// airport regardless of progress, so no mid-flight position window is needed at all.
await postSim(page, { paused: true });
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// Both trace endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [DEST, QUEUE]);
const destLonLat = await airportLonLat(page, DEST);
const queueLonLat = await airportLonLat(page, QUEUE);
const hubLonLat = await airportLonLat(page, HUB);
await row.hover();
// ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's
// destination (the queued stop EHAM), and NO segment may start near the departure airport — that
// was the old live-position behavior right after takeoff. Same per-tile clipping/quantization
// caveats as expectHoverRoute — check endpoints across all segments with EPS.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLonLat));
const endsAtQueue = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], queueLonLat);
});
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
return startsAtDest && endsAtQueue && noneStartsAtHub;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the landing airport (DEST) to the queued stop' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
// Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain),
// resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM,
// not DEST — it never delivered), then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // also clears the pause above
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
+226
View File
@@ -0,0 +1,226 @@
import type { Page } from '@playwright/test';
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';
// 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
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
// passes.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying' | 'repairing';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
}
interface CatalogEntry {
model: string;
burnPerKm: number;
emptyWeightKg: number;
maxFuel: number;
}
interface StateBody {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
catalog: CatalogEntry[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
async function setHealth(
page: Page,
body: { planeId: string; health?: number; repairUntilMs?: number },
): Promise<StateBody> {
const res = await page.request.post('/api/test/set-health', { data: body });
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
nearest = f.properties;
}
}
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
}
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
async function healStarterHome(page: Page): Promise<void> {
let s = await getState(page);
let starter = s.planes.find((p) => p.id === STARTER)!;
if (starter.status === 'repairing') {
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 30_000,
message: 'waiting for the shortened repair gate to flip plane-starter back',
})
.toBe('available');
s = await getState(page);
starter = s.planes.find((p) => p.id === STARTER)!;
}
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (starter.status === 'flying') {
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe('available');
s = await getState(page);
starter = s.planes.find((p) => p.id === STARTER)!;
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(HUB);
}
}
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
async function restoreStarter(page: Page): Promise<void> {
const s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!;
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
await setHealth(page, body);
}
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
// and leave a clean starter — full health, no active repair gate.
test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(res.status()).toBe(200);
await restoreStarter(page);
});
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
// (a) Force health to 42 — snapshot AND UI show it.
let s = await setHealth(page, { planeId: STARTER, health: 42 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
await page.goto('/');
const row = page.getByTestId(`plane-${STARTER}`);
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
.toContainText('42%');
// (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
s = await setHealth(page, { planeId: STARTER, health: 0 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
.toHaveText('broken', { timeout: 15_000 });
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
const nearest = await nearestToHub(page);
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!;
expect(starter.status).toBe('available'); // still grounded…
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
const cashBefore = s.account.cash;
const repairBtn = page.getByTestId(`repair-${STARTER}`);
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
// checked against the exact game clock the server used (a later GET would tick further).
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
repairBtn.click(),
]);
expect(res.status()).toBe(200);
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
const repaired = post.planes.find((p) => p.id === STARTER)!;
expect(repaired.status).toBe('repairing');
expect(repaired.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
.toHaveText('in repair', { timeout: 15_000 });
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.health === 100 ? true : false;
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
.toBe(true);
});
+13 -6
View File
@@ -69,9 +69,10 @@ async function postSim(page: Page, body: unknown) {
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -92,6 +93,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Load a package onto plane-starter and fly it from → to via the API, then poll at
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
@@ -197,10 +206,8 @@ test('plane markers move while flying and the plane popup shows status + route',
await loadAndFly(page, starterLoc, HUB);
}
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank,
// so a partially drained plane left by an earlier spec could not make it home.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
const pkg = await createPackage(page, HUB, DEST);
+12 -7
View File
@@ -22,7 +22,7 @@ async function waitForAirportCount(page: Page): Promise<number> {
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10);
}
test('page loads with the map and EU airport count', async ({ page }) => {
test('page loads with the map and world airport count', async ({ page }) => {
await page.goto(APP_URL);
await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page);
@@ -33,6 +33,11 @@ test('header shows the app version sourced from package.json', async ({ page })
await page.goto(APP_URL);
const header = page.getByTestId('app-header');
await expect(header).toContainText(`v${pkg.version}`);
// ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md.
await expect(page.getByTestId('app-version')).toHaveAttribute(
'href',
'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md',
);
});
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
@@ -52,11 +57,11 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
).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 to ~660 px at
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
// be overlapped by a neighbouring circle.
// 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(() => {
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
@@ -181,7 +186,7 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
// Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every
// source/layer not in the new style, so the airports layer must be re-established —
// count still shown and markers rendered again.
await expect(page.getByTestId('airport-count')).toContainText(/EU airports/);
await expect(page.getByTestId('airport-count')).toContainText(/world airports/);
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Clicking a marker after the switch opens exactly one popup (the click handler is
+46 -30
View File
@@ -4,6 +4,8 @@ import { test, expect, type Page } from '@playwright/test';
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
interface SimState {
clockMs: number;
timeScale: number;
@@ -60,48 +62,57 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
await expect(row).toContainText(truth.locationName);
}
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
// v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible();
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/);
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a
// Refuel heading or row anymore.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
});
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
await page.goto('/');
const pauseBtn = page.getByTestId('pause-toggle');
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
// Pause via the UI button — assert against the authoritative /api/state…
await pauseBtn.click();
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
// …and in the pane itself (the refetch after POST flips the button to Resume).
await expect(pauseBtn).toHaveText('Resume');
// Fastest scale so game seconds move visibly inside the sampling window.
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
expect(post.status()).toBe(200);
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
// optimistically on input, and release flushes the debounced POST immediately.
const slider = page.getByTestId('time-scale-slider');
await slider.focus();
await page.keyboard.press('End');
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
const sample = async () => {
const text = ((await clock.textContent()) ?? '').trim();
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// Home jumps to min (t=0 → 1×).
await page.keyboard.press('Home');
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
const t0 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t1 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(restore.status()).toBe(200);
const after = await getState(page);
expect(after.simState.timeScale).toBe(1);
expect(after.simState.paused).toBe(false);
expect(t1).toBeGreaterThan(t0);
expect(t2).toBeGreaterThan(t1);
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
// still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
expect(total).toBeGreaterThanOrEqual(1_000_000);
expect(total).toBeLessThanOrEqual(6_000_000);
});
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
@@ -140,4 +151,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market');
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
});
+608
View File
@@ -0,0 +1,608 @@
import { test, expect, type Page } from '@playwright/test';
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
import { loadAirportsGeoJson } from '../lib/airports';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
async function setCash(page: Page, amount: number): Promise<void> {
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
// leftover cargo so every test starts from the canonical state even after an interrupted run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(HUB);
}
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
}
async function waitForAirportCount(page: Page): Promise<void> {
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}).toBeGreaterThan(0);
}
// Rendered features of the given layers in the current viewport, [] until the map exists.
function renderedFeatures(
page: Page,
layers: string[],
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap;
if (!m) return [];
try {
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
type?: unknown;
id?: unknown;
properties?: unknown;
geometry?: unknown;
}>).map((f) => ({
type: f.type,
id: f.id,
properties: f.properties,
geometry: f.geometry,
}));
} catch {
return [];
}
}, layers);
}
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const points = icaos.map((icao) => {
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready');
const lons = pts.map((p) => p[0]);
const lats = pts.map((p) => p[1]);
// duration 0 → instant camera jump, no animation to wait out.
m.fitBounds(
[
[Math.min(...lons), Math.min(...lats)],
[Math.max(...lons), Math.max(...lats)],
],
{ padding: 40, duration: 0 },
);
}, points);
// Give the renderer a beat after the camera jump before asserting on rendered features.
await page.waitForTimeout(300);
}
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat];
}
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
// any two EU airports.
const QUEUE_ROUTE_EPS_DEG = 0.05;
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
const pkg = await createPackage(page, HUB, DEST);
await page.goto('/');
await waitForAirportCount(page);
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
await loadBtn.click();
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === DEST;
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
expect(dupQueue.status()).toBe(409);
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
// posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await destSelect.selectOption(QUEUE);
await page.getByTestId('relocate-go').click();
// …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-plane-starter-0');
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
// CANCEL it via the UI and assert it's gone.
await page.getByTestId('queue-cancel-plane-starter-0').click();
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — keeps the state canonical for whatever the cleanup does (ISS-082 hook).
await setFuelFull(page, 'plane-starter');
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
await postSim(page, { paused: true });
await page.goto('/');
await waitForAirportCount(page);
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [DEST, QUEUE]);
const destLL = await airportLonLat(page, DEST);
const queueLL = await airportLonLat(page, QUEUE);
const hubLL = await airportLonLat(page, HUB);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
properties?: Record<string, unknown>;
geometry?: { type: string; coordinates: [number, number][] };
}>;
// Filter to real LineString segments — keeps this poll callback total (a throw inside
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
const segs = feats.filter(
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
// …and some ENDS within EPS of the queued airport.
const endsAtQueue = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], queueLL);
});
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
// Every rendered feature belongs to plane-starter (the only queued plane).
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
// to arrival, then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('grounded plane with a pre-built queue draws its dotted route from its airport (ISS-077)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + map assertions only — no flight in this test
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
await page.goto('/');
await waitForAirportCount(page);
// Pre-build a two-leg route while GROUNDED via the raw API (queuing is allowed from any state,
// ISS-043 part 1) — no package or flight needed. The dotted line must chain HUB → DEST → QUEUE.
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
expect(q1.status()).toBe(200);
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q2.status()).toBe(200);
// All chain endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [HUB, DEST, QUEUE]);
const hubLL = await airportLonLat(page, HUB);
const destLL = await airportLonLat(page, DEST);
const queueLL = await airportLonLat(page, QUEUE);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
properties?: Record<string, unknown>;
geometry?: { type: string; coordinates: [number, number][] };
}>;
// Filter to real LineString segments — keeps this poll callback total. "None yet" → keep polling.
const segs = feats.filter(
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The chain HUB → DEST → QUEUE may be clipped into per-tile segments — check across all of them:
// some segment STARTS within EPS of the hub (the plane's airport, NOT a flight destination)…
const startsAtHub = segs.some((f) => near(f.geometry.coordinates[0], hubLL));
// …some ENDS within EPS of the last queued airport…
const endsAtQueue = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], queueLL);
});
// …and the chain passes THROUGH the first queued airport — an interior joint point survives in
// whichever tile contains it (not guaranteed to be a segment endpoint after clipping).
const passesThroughDest = segs.some((f) => f.geometry.coordinates.some((c) => near(c, destLL)));
// Every rendered feature belongs to plane-starter.
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
return startsAtHub && endsAtQueue && passesThroughDest && ownedByStarter;
}, { timeout: 15_000, message: `waiting for the dotted queue line ${HUB} → ${DEST} → ${QUEUE}` }).toBe(true);
// Cleanup: cancel both queued legs (index 0 twice — each cancel shifts the rest down) so the
// starter stays grounded at HUB with an empty queue. No flight happened, nothing else to reset.
const c1 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c1.status()).toBe(200);
const c2 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c2.status()).toBe(200);
});
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
await flyHomeViaApi(page);
// Full tank for a deterministic baseline — the engine auto-chains (and auto-refuels, ISS-082) the return leg regardless.
await setFuelFull(page, 'plane-starter');
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
const s = await getState(page);
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
});
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
for (const icao of [DEST, QUEUE, HUB]) {
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
}
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
expect(p0.status).toBe('flying');
expect(p0.destIcao).toBe(DEST);
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
// Landing at DEST auto-chains EHAM (leg 2)…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
// …and landing at EHAM chains the return to HUB (leg 3).
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
});
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two legs at scale 1000
await flyHomeViaApi(page);
const s0 = await getState(page);
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
await setFuelFull(page, 'plane-starter');
const STAGED_CASH = 20_000;
await setCash(page, STAGED_CASH);
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
await setFuel(page, 'plane-starter', 0);
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
const fc = await loadAirportsGeoJson();
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
const wHub = weatherFor(HUB);
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
expect(landedFuel).toBeCloseTo(0, 6);
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
const liters = Math.max(0, target - landedFuel);
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
const s = await getState(page);
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
// Let leg 2 finish so the next test starts from a known airport.
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
});
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one leg at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
expect(p.destIcao).toBeNull(); // no chained leg started…
expect(p.queue).toEqual([]); // …and the queue is empty
});
+197
View File
@@ -0,0 +1,197 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// 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)
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
// the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
// resume it or the clock stays frozen and the next wait hangs.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
async function nearestToHub(page: Page): Promise<{ icao: string }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao };
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — repositioning needs no package (v0.12.0 manual
// flight) — so every test starts from the canonical state even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
// before deciding whether a repositioning flight is needed.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
}
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
const nearest = await nearestToHub(page);
await page.goto('/');
const planeSel = page.getByTestId('relocate-plane');
await expect(planeSel).toBeVisible({ timeout: 15_000 }); // pane renders once the first snapshot lands
await planeSel.selectOption('plane-starter');
// Destination options come from the one-time /api/airports fetch (sorted by name) — selectOption
// waits until the matching option exists, so this doubles as the load wait.
const destSel = page.getByTestId('relocate-dest');
await destSel.selectOption(nearest.icao);
const goBtn = page.getByTestId('relocate-go');
await expect(goBtn).toBeEnabled();
await goBtn.click();
// The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view),
// so poll until it took effect on the server.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the relocate click to take effect (status "flying" + dest set)' }).toBe(true);
});
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
await page.goto('/');
// Wait until the airport count renders (airports loaded) and the map has a snapshot containing
// the plane — the popup's Relocate row is built from that same latest snapshot.
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}, { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () =>
page.evaluate(() => {
const m = (window as any).__flightMap;
if (!m || !m.getSource('planes')) return false;
try {
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
} catch {
return false; // source not ready yet — keep polling
}
}),
{ timeout: 15_000 },
).toBe(true);
// 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 }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, POPUP_AIRPORT);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText(POPUP_AIRPORT.icao);
// The Relocate row: a select of grounded planes + the button (disabled until one is picked).
const planeSel = page.getByTestId('airport-popup-plane');
await expect(planeSel).toBeVisible();
// Target THE PLANE OPTION specifically — locator('option') also matches the placeholder and
// trips strict mode (CI run #51).
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
const goBtn = page.getByTestId('airport-popup-relocate');
await expect(goBtn).toBeDisabled(); // placeholder selected
await planeSel.selectOption('plane-starter');
await expect(goBtn).toBeEnabled();
await goBtn.click();
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
});
+201 -118
View File
@@ -1,11 +1,21 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
// 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 shows
// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
// match the catalog + economy constants exposed in /api/state. Packages are created via
// 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 +
// abandon buttons (penalty preview from the same
// formula as the server); flying planes show their current leg; market buy/sell previews match
// the catalog + economy constants exposed in /api/state, and dispatch auto-refuels a low tank to
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
@@ -17,6 +27,7 @@ interface PlaneState {
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
locationIcao: string;
destIcao: string | null;
}
@@ -26,10 +37,11 @@ interface PackageState {
originName: string;
destName: string;
reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
}
interface CatalogEntry {
@@ -40,7 +52,7 @@ interface CatalogEntry {
burnPerKm: number;
emptyWeightKg: number; // v0.16.0 weighted burn input
speedKmh: number;
capacity: number;
maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
}
interface StateBody {
@@ -67,9 +79,10 @@ async function postSim(page: Page, body: unknown) {
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -100,7 +113,8 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
}
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
// Returns both endpoints' coordinates so exact-km fuel assertions can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
@@ -112,32 +126,7 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
nearest = f.properties;
}
}
return nearest;
}
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
// this test wants a long-but-flyable leg instead.
async function farthestFlyableToHub(
page: Page,
entry: CatalogEntry,
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
}
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
return { ...farthest, hubLat: hub.lat, hubLon: hub.lon };
return { ...nearest, hubLat: hub.lat, hubLon: hub.lon };
}
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
@@ -148,10 +137,17 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
@@ -159,7 +155,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before the steps below act on the starter, so the
// farthest-flyable picker and fuel pins never see a worn or broken plane.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -174,8 +174,12 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
@@ -183,6 +187,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
})
.toBe(true);
}
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
}
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
@@ -198,13 +210,20 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao);
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
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 });
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled();
@@ -219,6 +238,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
});
test('a priority package shows its special badge on the open-package row', 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, undefined, 4_000, 'priority');
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
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 });
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
const badge = page.getByTestId(`package-special-${pkg.id}`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', 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
@@ -256,8 +292,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible();
await expect(leg).toContainText('→');
@@ -284,6 +320,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run.
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200);
await expect
@@ -294,21 +332,33 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
.toBe(true);
});
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
test('market buy/sell render with previews from the catalog in /api/state', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
expect(body.catalog.length).toBeGreaterThan(0);
const entry = body.catalog.find((e) => e.model === 'atr72')!;
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts)
await page.goto('/');
// Buy: the model picker lists the catalog entry and the price preview is its full price.
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
// Buy: the model picker lists the catalog entry and the price preview is its EFFECTIVE price —
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list price.
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 });
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
if (body.account.reputation > STARTING_REPUTATION) {
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
} else {
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
}
await expect(page.getByTestId('market-buy')).toBeVisible();
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
@@ -318,97 +368,130 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
// Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < starter.maxFuel) {
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
const cashBefore = body.account.cash;
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
await page.getByTestId('market-refuel').click();
// Cash decreased by exactly the expected cost and the tank is full (server truth).
await expect
.poll(async () => {
const b = await getState(page);
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
.toBe(true);
const after = await getState(page);
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
}
});
test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead. This test pins the new
// behavior end-to-end: drop a grounded starter to low fuel via the hook, fly a leg, and assert the
// cash delta equals the EXACT rep-discounted top-up cost (computed from the snapshot + real lib
// helpers) while the post-flight landed fuel matches the weighted burn from the topped start value.
test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement and deducts exactly', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short flight each way at scale 1000
await flyHomeViaApi(page);
const body = await getState(page);
const entry = body.catalog.find((e) => e.model === 'atr72')!;
// Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Drop the grounded starter to a low, deterministic fuel level via the test hook — well below the
// 150%-of-requirement target even for the shortest dataset leg (EDFH, ~93 km), so a top-up is guaranteed.
const LOW_FUEL = 50;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: LOW_FUEL } })).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
// exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Nearest airport to the hub → short leg, fast at scale 1000.
const nearest = await nearestToHub(page);
const pre = await getState(page);
const starter = pre.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
// Fly there (delivers pkg1 on arrival) — the tank is now partial.
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200);
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
// wind-adjusted requirement, 150% margin capped at tank capacity.
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
);
const target = Math.min(entry.maxFuel, Math.max(LOW_FUEL, required * 1.5)); // ISS-082: 150% margin, capped at tank capacity
const liters = Math.max(0, target - LOW_FUEL);
expect(liters).toBeGreaterThan(0); // the low tank genuinely needs a top-up
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
const cashBefore = pre.account.cash;
// Dispatch via the API — the response snapshot is authoritative at the action's clock.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200); // auto-refuel made the low tank flyable…
const post = (await flyRes.json()) as StateBody;
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // …departing with exactly the topped level
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
// Fast-forward to arrival; the landed fuel is the weighted burn from the TOPPED start value.
await setScale(page, SCALE);
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === farthest.icao;
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` })
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` })
.toBe(true);
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for.
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
const landed = await getState(page);
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
6,
);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
await page.goto('/');
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 });
// Capture the endpoint's OWN response — the full post-action snapshot at the action's clock.
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }),
refuelBtn.click(),
]);
expect(res.status()).toBe(200);
const post = (await res.json()) as StateBody;
// Cash dropped by exactly L × price and fuel rose by exactly L.
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
// Market row: the fill-up button is labeled "Refuel to full" (testid unchanged).
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough.
// Restore: fly home (auto-refuel covers the return leg) and top the tank back up for later specs.
await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
// Top the tank back up so later specs start with a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
});
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
await ignoreBtn.click();
// Server truth: the package is 'ignored'…
await expect
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
timeout: 15_000,
message: 'waiting for the ignored status in /api/state',
})
.toBe('ignored');
// …and the row is gone from the open list (the section filters status==='open').
await expect(row).not.toBeVisible({ timeout: 15_000 });
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(again.status()).toBe(409);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
expect(body.catalog.length).toBe(6);
await page.getByTestId('tab-planes-market').click();
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!;
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
const b777 = page.getByTestId('plane-type-b777');
await expect(b777).toBeVisible();
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
});
+141
View File
@@ -0,0 +1,141 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
simState: SimState;
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.status()).toBe(200);
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as SimState;
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
async function nearestToHub(page: Page): Promise<{ icao: string }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao };
}
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API so every test starts from the canonical state even
// after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
// flight is needed.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
}
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
const nearest = await nearestToHub(page);
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
await setScale(page, SCALE);
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200);
// Wait for the landing…
let landedBody: StateBody | undefined;
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
return !!landedBody;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
.toBe(true);
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
expect(landedBody!.simState.paused).toBe(false);
expect(landedBody!.simState.timeScale).toBe(1);
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
// resumes it.
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(fly2.status()).toBe(200);
const resumedBody = (await fly2.json()) as StateBody;
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
// Let the return leg finish so cleanup starts from a grounded plane.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
});
+47 -1
View File
@@ -36,7 +36,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(Array.isArray(body.packages)).toBe(true);
expect(body.packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
}
});
@@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
});
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
test.setTimeout(180_000);
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
.toBe('available');
// Stage a two-leg queue on the starter…
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
expect(q1.status()).toBe(200);
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
expect(q2.status()).toBe(200);
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
// Cleanup: cancel both legs so the starter is left with an empty queue.
for (let i = 0; i < 2; i++) {
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c.status()).toBe(200);
}
});
+80
View File
@@ -0,0 +1,80 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// 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
// for browser notifications.
interface CatalogEntry {
model: string;
label: string;
price: number;
}
interface StateBody {
account: { cash: number };
catalog: CatalogEntry[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
await page.goto('/');
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
// Part A — deterministic error path: the most expensive catalog model (ATR 72-600, €200k) is
// unaffordable with starting cash (€20k). On a long-lived local instance cash may have grown
// past it — then this part is skipped in place; CI's isolated stack always starts fresh.
const body = await getState(page);
const priciest = [...body.catalog].sort((a, b) => b.price - a.price)[0];
if (body.account.cash < priciest.price) {
await buyModel.selectOption(priciest.model);
await page.getByTestId('market-buy').click();
// The toast appears with the server's error message and the error variant class…
const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i });
await expect(toast, 'the error toast appears at the map right edge').toBeVisible({ timeout: 10_000 });
await expect(toast).toHaveClass(/toast-error/);
// …and the inline pane error still shows (both surfaces by design). It auto-clears on the
// next poll (~1 s), so assert right after the toast — both are set in the same commit.
await expect(page.getByTestId('right-error')).toContainText(/insufficient funds/);
}
// Part B — notifications opt-in in the Time section.
const btn = page.getByTestId('enable-notifications');
await expect(btn, 'the notifications button renders in the Time section').toBeVisible({ timeout: 15_000 });
// Browsers without the Notification API: disabled with a tooltip-ish title.
const supported = await page.evaluate(() => 'Notification' in window);
if (!supported) {
await expect(btn).toBeDisabled();
await expect(btn).toHaveAttribute('title', /does not support/i);
return;
}
// Branch on the ACTUAL permission state — headless CI reports 'denied' (not 'default'), so a
// fresh context is not guaranteed to show the opt-in prompt (CI run #47).
const permission = await page.evaluate(() => Notification.permission);
if (permission === 'denied') {
// LeftPane renders the blocked-state label without any click.
await expect(btn, 'denied context shows the blocked-state label').toHaveText('Notifications blocked', { timeout: 10_000 });
} else if (permission === 'granted') {
await expect(btn, 'pre-granted context shows the enabled label').toHaveText('Notifications enabled', { timeout: 10_000 });
} else {
// 'default' — the opt-in prompt. Clicking requests it; headless may deny, so accept either
// outcome, but the label must CHANGE from the prompt to reflect the result.
await expect(btn, 'non-denied context shows the opt-in prompt').toHaveText('Enable notifications', { timeout: 10_000 });
await btn.click();
await expect(btn).not.toHaveText('Enable notifications', { timeout: 10_000 });
}
});
+566
View File
@@ -0,0 +1,566 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// 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
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
//
// Each test buys its own second plane via the UI (after a cash grant from the new
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
}
interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
emptyWeightKg: number;
speedKmh: number;
maxPayloadKg: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
// proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)
.slice(0, 2)
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is flown after a top-up, which also 409s if it doesn't fit.
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
}
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
async function createPackage(
page: Page,
fromIcao: string,
toIcao: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
// post-grant snapshot (the route's response).
async function addCash(page: Page, amount?: number): Promise<StateBody> {
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
async function buySecondPlaneViaUi(page: Page): Promise<string> {
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible({ timeout: 15_000 });
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
await buyModel.selectOption(BUY_MODEL);
await page.getByTestId('market-buy').click();
let secondId: string | null = null;
await expect
.poll(async () => {
const s = await getState(page);
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
return secondId !== null;
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
.toBe(true);
return secondId!;
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
// carrying cargo, and clean baselines keep exact-cash assertions honest.
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
for (const pkg of s.packages) {
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
}
}
}
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
// specs, generalized to an arbitrary plane id.
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
await clearQueueViaApi(page, planeId);
let p = (await getState(page)).planes.find((x) => x.id === planeId);
if (!p) return; // already sold — nothing to heal
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
await setScale(page, SCALE);
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => {
const s = await getState(page);
const plane = s.planes.find((x) => x.id === planeId);
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
return plane?.status ?? '';
}, {
timeout: 240_000,
message: `waiting for an in-flight ${planeId} to land`,
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
}
if (p.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for ${planeId} to land back at the hub`,
})
.toBe(HUB);
}
}
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
// in flight) — leaves the fleet at exactly one plane.
async function sellNonStarters(page: Page): Promise<void> {
const s = await getState(page);
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
await abandonLeftoverCargo(page, p.id);
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
}
}
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
// no cargo on board — even after an interrupted previous run.
async function resetFleet(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
await sellNonStarters(page);
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
await abandonLeftoverCargo(page, STARTER);
}
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
test.beforeEach(async ({ page }) => {
await resetFleet(page);
});
test.afterEach(async ({ page }) => {
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
const [nearest] = await twoNearestToHub(page);
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
// The multi-plane picker lists BOTH planes…
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
await expect(picker).toBeVisible({ timeout: 15_000 });
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
const s0 = await getState(page);
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
// One-click Load binds the package to the preselected (first eligible) plane.
await loadBtn1.click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
.toBe(firstEligible.id);
// Second package — pick the OTHER plane explicitly via the dropdown.
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
await expect(picker2).toBeVisible({ timeout: 15_000 });
await picker2.selectOption(secondId);
await page.getByTestId(`load-${pkg2.id}`).click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
.toBe(secondId);
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// Full tanks for both — the starter's may be partial from an earlier run.
await setFuelFull(page, STARTER);
await setFuelFull(page, secondId);
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
const [nearest] = await twoNearestToHub(page);
const farthest = await farthestFlyableToHub(page);
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyA.status()).toBe(200);
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
expect(flyB.status()).toBe(200);
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
const s1 = (await flyB.json()) as StateBody;
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
// Fast-forward to the FIRST landing (the short hop)…
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for the first plane to land',
})
.toBe(true);
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for both planes to land',
})
.toBe(true);
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState(page);
expect(landed.simState.paused).toBe(false);
expect(landed.simState.timeScale).toBe(1);
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
await setFuelFull(page, secondId);
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
expect(flyB2.status()).toBe(200);
let body = (await flyB2.json()) as StateBody;
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
// …but dispatching the LAST grounded plane restores the last manually selected speed.
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA2.status()).toBe(200);
body = (await flyA2.json()) as StateBody;
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
});
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
// its absence is pinned in e2e/panes.spec.ts instead.
test('relocate section moves one plane without touching the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
// The Relocate section lists BOTH grounded planes…
const relocateSelect = page.getByTestId('relocate-plane');
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
// Relocate ONLY the second plane…
await relocateSelect.selectOption(secondId);
await destSelect.selectOption(a.icao);
await page.getByTestId('relocate-go').click();
// …it is flying to the new airport while the starter stays put at EDDF.
await expect
.poll(async () => {
const s = await getState(page);
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
.toBe(true);
// Let it land — locationIcao flips to the destination on arrival.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for the relocated plane to land at ${a.icao}`,
})
.toBe(a.icao);
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
});
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
const secondId = await buySecondPlaneViaUi(page);
const [a, b] = await twoNearestToHub(page);
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
await setFuelFull(page, STARTER);
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
// response snapshot must already show only [b] left (previously the leg stayed queued and the
// landing chained a zero-distance self-hop a→a before reaching b).
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200);
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale(page, SCALE);
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true);
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
// is either the outbound leg (HUB→a) or the chained leg (a→b).
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
// …then restore speed so the chained leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
return !!p && p.status === 'available' && p.locationIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
.toBe(true);
// The second plane never moved — still grounded at EDDF with an empty queue.
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
});
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
// The Sell row lists BOTH grounded planes…
const sellSelect = page.getByTestId('market-sell-plane');
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
await sellSelect.selectOption(secondId);
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
await page.getByTestId('market-sell').click();
await expect
.poll(async () => {
const s = await getState(page);
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
.toBe(true);
// The last remaining plane can never be sold — even via the API.
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
expect(res.status()).toBe(409);
});
+22 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports';
import { assertUniqueIcaos, filterWorldAirports, parseCsv, toGeoJson, airportsHandler } from './airports';
const HEADER =
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
@@ -53,25 +53,34 @@ describe('parseCsv', () => {
});
});
describe('filterEuAirports', () => {
it('keeps EU fixed-wing airports and seaplane bases, drops the rest', () => {
describe('filterWorldAirports (v0.44.0 — world dataset, no continent check)', () => {
it('keeps fixed-wing airports and seaplane bases from every continent, drops the rest by type', () => {
const rows = parseCsv(
[
HEADER,
row({ ident: 'EU-SMALL', type: 'small_airport' }),
row({ ident: 'EU-MED', type: 'medium_airport' }),
row({ ident: 'EU-LRG', type: 'large_airport' }),
row({ ident: 'NA-LRG', continent: 'NA', type: 'large_airport' }),
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
row({ ident: 'EU-SEA', type: 'seaplane_base' }),
row({ ident: 'EU-LEGACY', type: 'airport' }),
row({ ident: 'EU-Helio', type: 'heliport' }),
row({ ident: 'EU-CLOSED', type: 'closed' }),
row({ ident: 'EU-BALLOON', type: 'balloonport' }),
row({ ident: 'NA-SMALL', continent: 'NA' }),
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
row({ ident: 'NA-Helio', continent: 'NA', type: 'heliport' }),
row({ ident: 'AS-CLOSED', continent: 'AS', type: 'closed' }),
].join('\n'),
);
const kept = filterEuAirports(rows).map((r) => r.ident);
expect(kept.sort()).toEqual(['EU-LEGACY', 'EU-LRG', 'EU-MED', 'EU-SEA', 'EU-SMALL']);
const kept = filterWorldAirports(rows).map((r) => r.ident);
expect(kept.sort()).toEqual(['AS-LRG', 'EU-LEGACY', 'EU-SEA', 'EU-SMALL', 'NA-LRG']);
});
});
describe('assertUniqueIcaos (v0.44.0 load-time guard)', () => {
it('passes a collection with unique ICACOs and throws on the first duplicate', () => {
const ok = toGeoJson(parseCsv([HEADER, row({ ident: 'AAAA', icao_code: 'AAAA' }), row({ ident: 'BBBB', icao_code: 'BBBB' })].join('\n')));
expect(() => assertUniqueIcaos(ok)).not.toThrow();
// icao_code empty → the effective key is the ident fallback (toGeoJson), so a duplicate
// ident must trip the guard too.
const dup = toGeoJson(parseCsv([HEADER, row({ ident: 'CCCC', icao_code: '' }), row({ ident: 'CCCC', icao_code: '', latitude_deg: '51.0' })].join('\n')));
expect(() => assertUniqueIcaos(dup)).toThrow(/duplicate ICAO.*CCCC/);
});
});
@@ -102,7 +111,7 @@ describe('toGeoJson', () => {
});
describe('/api/airports handler', () => {
it('returns a valid GeoJSON FeatureCollection of EU airports', async () => {
it('returns a valid GeoJSON FeatureCollection of world airports', async () => {
const res = await airportsHandler();
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
+20 -5
View File
@@ -52,11 +52,13 @@ export function parseCsv(text: string): CsvRow[] {
// The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a
// single "airport" type + category column) — both spellings are accepted. Heliports are
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are
// irrelevant to an airline-ops game.
// irrelevant to an airline-ops game. The committed dataset is pre-curated to large_airport
// only (scripts/build-world-airports.mjs); this check stays as a cheap guard against an
// uncurated dump being swapped in.
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
export function filterEuAirports(rows: CsvRow[]): CsvRow[] {
return rows.filter((r) => r.continent === 'EU' && INCLUDED_TYPES.has(r.type));
export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
return rows.filter((r) => INCLUDED_TYPES.has(r.type));
}
export interface AirportProperties {
@@ -93,13 +95,26 @@ export function toGeoJson(rows: CsvRow[]): GeoJsonFeatureCollection {
return { type: 'FeatureCollection', features };
}
// Three consumers key features by ICAO with silent-overwrite semantics (the map's airport
// index, the route/queue GeoJSON builders and the sim's airport lookups) — a duplicate would
// corrupt state without any error, so fail loud at load instead.
export function assertUniqueIcaos(fc: GeoJsonFeatureCollection): void {
const seen = new Set<string>();
for (const f of fc.features) {
if (seen.has(f.properties.icao)) throw new Error(`duplicate ICAO in airport dataset: ${f.properties.icao}`);
seen.add(f.properties.icao);
}
}
let cached: GeoJsonFeatureCollection | null = null;
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
if (cached) return cached;
const file = path.join(process.cwd(), 'data', 'airports_eu.csv');
const file = path.join(process.cwd(), 'data', 'airports_world.csv');
const text = await fs.readFile(file, 'utf8');
cached = toGeoJson(filterEuAirports(parseCsv(text)));
const fc = toGeoJson(filterWorldAirports(parseCsv(text)));
assertUniqueIcaos(fc);
cached = fc;
return cached;
}
+5 -1
View File
@@ -151,7 +151,8 @@ describe('openGameDb', () => {
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, 1, 0)');
// time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale.
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
const file = path.join(dir, 'legacy.db');
await fs.writeFile(file, Buffer.from(raw.export()));
@@ -167,6 +168,9 @@ describe('openGameDb', () => {
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
// v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default.
expect(sim?.timeScale).toBe(50);
expect(sim?.lastSelectedTimeScale).toBe(50);
});
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
+37 -3
View File
@@ -30,7 +30,10 @@ CREATE TABLE IF NOT EXISTS planes (
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]',
health REAL NOT NULL DEFAULT 100,
repair_until_sim_time INTEGER
);
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL,
@@ -39,6 +42,8 @@ CREATE TABLE IF NOT EXISTS packages (
reward REAL NOT NULL,
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
special TEXT,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
@@ -52,7 +57,8 @@ CREATE TABLE IF NOT EXISTS 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_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1
);
`;
@@ -72,6 +78,32 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('dest_icao')) {
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
}
// v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs.
if (!cols('planes').has('queue')) {
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
}
// v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default.
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
// v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
if (!cols('packages').has('special')) {
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
}
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
if (!cols('planes').has('health')) {
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
}
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
if (!cols('planes').has('repair_until_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time 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');
}
}
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
@@ -159,7 +191,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
const rng = mulberry32(randomInt(0, 2 ** 31));
const data = buildSeed({ airports, rng, nowMs: Date.now() });
await instance.db.insert(schema.planes).values(data.plane);
// 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 ?? []) });
await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState);
+24 -2
View File
@@ -7,7 +7,9 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(),
model: text('model').notNull(),
status: text('status', { enum: ['available', 'flying'] }).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'.
status: text('status', { enum: ['available', 'flying', 'repairing'] }).notNull(),
fuel: real('fuel').notNull(),
maxFuel: real('max_fuel').notNull(),
locationIcao: text('location_icao').notNull(),
@@ -20,6 +22,16 @@ export const planes = sqliteTable('planes', {
// flying, `fuel` is frozen at its departure value — live position/fuel are pure
// functions of the clock (lib/sim/movement.ts), recomputed on read.
departureSimTime: integer('departure_sim_time'),
// Queue of upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered JSON array
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
queue: text('queue').notNull().default('[]'),
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
// landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
health: real('health').notNull().default(100),
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
repairUntilSimTime: integer('repair_until_sim_time'),
});
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
@@ -33,10 +45,16 @@ export const packages = sqliteTable('packages', {
reward: real('reward').notNull(),
cargoType: text('cargo_type').notNull(),
cargoCount: integer('cargo_count').notNull(),
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
weightKg: real('weight_kg').notNull().default(2000),
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
special: text('special'),
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.
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
// '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(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
});
@@ -53,6 +71,10 @@ export const simState = sqliteTable('sim_state', {
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
lastRealTickMs: integer('last_real_tick_ms').notNull(),
// Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without
// touching this; flyPlane restores it when the last grounded plane takes off. Written by
// POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale.
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
});
export type PlaneRow = typeof planes.$inferSelect;
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import type { PackageState, PlaneState, StateSnapshot } from './game/state';
import { diffSnapshots } from './deriveEvents';
// ISS-008 (v0.17.0): snapshot-diffing event derivation — pure core of the toast/notification
// feature, unit-tested in node like advanceSimClock/fetchState.
const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, maxPayloadKg: 4800 },
];
const basePlane = (id: string): PlaneState => ({
id,
model: 'atr72',
status: 'available',
fuel: 6300,
maxFuel: 6300,
locationIcao: 'EDDF',
locationName: 'Frankfurt',
destIcao: null,
destName: null,
departureSimTime: null,
lat: 50.03,
lon: 8.56,
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null,
queue: [],
});
const basePackage = (id: string): PackageState => ({
id,
originIcao: 'EDDF',
originName: 'Frankfurt',
destIcao: 'LEMD',
destName: 'Madrid–Barajas',
reward: 1234.5,
cargoType: 'mail',
cargoCount: 2,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0,
status: 'open',
planeId: null,
createdSimTime: 0,
});
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0, timeScale: 1, paused: false },
planes: [basePlane('plane-starter')],
packages: [],
catalog: CATALOG,
fuelPricePerL: 0.8,
sellFraction: 0.5,
...over,
});
describe('diffSnapshots', () => {
it('returns [] for identical snapshots (only clock/cash drift)', () => {
const prev = snapshot();
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } });
expect(diffSnapshots(prev, next)).toEqual([]);
});
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => {
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(null, next)).toEqual([]);
});
it('emits a landing event when a plane goes flying → available (new locationName is the arrival)', () => {
const prev = snapshot({
planes: [
{
...basePlane('plane-starter'),
status: 'flying',
destIcao: 'LEMD',
destName: 'Madrid–Barajas',
departureSimTime: 0,
},
],
});
const next = snapshot({
planes: [
{ ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' },
],
});
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' },
]);
});
it('emits a delivery event with the reward when a loaded package is delivered on arrival', () => {
const prev = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }] });
const next = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'Package delivered: +€1,234.50' },
]);
});
it('emits an expiry event for open → expired and loaded → expired', () => {
const fromOpen = diffSnapshots(
snapshot({ packages: [basePackage('pkg-1')] }),
snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'expired' }] }),
);
expect(fromOpen).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
const fromLoaded = diffSnapshots(
snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'loaded', planeId: 'plane-starter' }] }),
snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'expired', planeId: 'plane-starter' }] }),
);
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(),
snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }),
);
expect(bought).toEqual([{ kind: 'info', message: 'Bought ATR 72-600' }]);
const sold = diffSnapshots(
snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }),
snapshot(),
);
expect(sold).toEqual([{ kind: 'info', message: 'Sold ATR 72-600' }]);
});
it('reports every transition in one diff (planes first, then packages)', () => {
const prev = snapshot({
planes: [
{ ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' },
basePlane('plane-2'),
],
packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }],
});
const next = snapshot({
planes: [
{ ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' },
basePlane('plane-3'),
],
packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }],
});
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' },
{ kind: 'info', message: 'Bought ATR 72-600' },
{ kind: 'info', message: 'Sold ATR 72-600' },
{ kind: 'info', message: 'Package delivered: +€1,234.50' },
]);
});
it('ignores transitions that are not events (open → loaded, available → flying)', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({
planes: [{ ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' }],
packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }],
});
expect(diffSnapshots(prev, next)).toEqual([]);
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { StateSnapshot } from './game/state';
import { PLANE_TYPES } from './game/catalog';
import { formatCash } from './format';
// ISS-008 (v0.17.0): derive human-readable game events by diffing two consecutive /api/state
// snapshots — there is no server event feed, so the client watches for transitions in the
// fields it already receives. Pure and unit-tested; the shell calls it once per new snapshot
// and turns each result into an info toast (plus a browser notification when hidden).
export interface GameEvent {
kind: 'info';
message: string;
}
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.
if (prev === null) return [];
const events: GameEvent[] = [];
// Planes: landed (flying → available, new locationName is the arrival airport), bought or
// sold (id set changed — ids are stable for a plane's whole life).
const prevPlanes = new Map(prev.planes.map((p) => [p.id, p]));
for (const plane of next.planes) {
const before = prevPlanes.get(plane.id);
if (!before) {
events.push({ kind: 'info', message: `Bought ${planeName(plane.model)}` });
continue;
}
if (before.status === 'flying' && plane.status === 'available') {
events.push({ kind: 'info', message: `${planeName(plane.model)} landed at ${plane.locationName}` });
}
}
for (const [id, before] of prevPlanes) {
if (!next.planes.some((p) => p.id === id)) {
events.push({ kind: 'info', message: `Sold ${planeName(before.model)}` });
}
}
// 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).
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.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') {
events.push({ kind: 'info', message: `Package expired: ${pkg.originName} → ${pkg.destName}` });
}
}
return events;
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
@@ -21,6 +21,23 @@ describe('formatFuel', () => {
});
});
describe('formatCargo', () => {
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
});
});
describe('formatKm', () => {
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
expect(formatKm(554)).toBe('554km');
expect(formatKm(214.6)).toBe('215km');
expect(formatKm(0)).toBe('0km');
});
});
describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
+15
View File
@@ -11,6 +11,21 @@ export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`;
}
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
export function formatKm(km: number): string {
return `${Math.round(km)}km`;
}
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1);
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string {
+428 -148
View File
@@ -6,17 +6,32 @@ 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 { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import {
FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT,
MAX_PLANE_DISCOUNT,
PLANE_DISCOUNT_PER_REP,
PLANE_TYPES,
REPAIR_COST,
REPAIR_DURATION_MS,
repRate,
SELL_FRACTION,
} from './catalog';
import { HUB_ICAO } from './seed';
import {
abandonPackage,
buyPlane,
cancelQueuedLeg,
flyPlane,
GameActionError,
ignorePackage,
loadPackage,
refuelPlane,
queueLeg,
repairPlane,
sellPlane,
} from './actions';
@@ -125,27 +140,27 @@ describe('loadPackage', () => {
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
});
it('rejects loading when the plane has no free slot (capacity from catalog)', async () => {
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
const inst = await freshDb('load-full.db');
await insertOpenPackage(inst, 'p-new');
// Fill all atr72 slots with loaded cargo…
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Fill past maxPayloadKg with loaded cargo…
const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
for (let i = 0; i < 3; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
}
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
});
it('counts expired packages as occupying their slot too', async () => {
it('counts expired packages as occupying their payload too', async () => {
const inst = await freshDb('load-expiredslot.db');
await insertOpenPackage(inst, 'p-new');
// capacity − 1 loaded + 1 expired = full.
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
}
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Half + half (expired) leaves no room — each alone would fit.
const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
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 free slots/i);
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
});
});
@@ -167,6 +182,34 @@ describe('flyPlane', () => {
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
});
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
const inst = await freshDb('fly-resume.db');
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
});
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
const inst = await freshDb('fly-stayslow.db');
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
// A second grounded plane — the starter taking off leaves it on the ground.
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await flyPlane(inst, 'plane-starter', 'LEMD');
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('fly-flying.db');
await inst.db.update(schema.planes)
@@ -186,11 +229,235 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
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;
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 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
});
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
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
});
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
// Wind-adjusted requirement for the hub → LEMD leg — computed with the SAME helpers flyPlane uses,
// so the expectations stay exact without hardcoded literals.
async function adjustedRequiredForHubToLemd() {
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('auto-refuels on dispatch: tops up to min(maxFuel, max(fuel, 1.5 × required)) and deducts the rep-discounted cost', async () => {
const inst = await freshDb('fly-autorefuel.db'); // EDDF → LEMD
const required = await adjustedRequiredForHubToLemd();
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 flyPlane(inst, 'plane-starter', 'LEMD');
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
const target = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
const expectedCost = Math.round((target - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
expect(expectedCost).toBeGreaterThan(0); // the top-up genuinely costs something
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
});
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;
await flyPlane(inst, 'plane-starter', 'LEMD');
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
});
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
const inst = await freshDb('fly-autorefuel-poor.db'); // EDDF → LEMD
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 expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(startFuel); // …with the fuel UNCHANGED — nothing was mutated
});
it('still rejects a leg that does not fit even a full tank with 409 (the auto-refuel backstop)', async () => {
const inst = await freshDb('fly-autorefuel-nofit.db');
// 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);
});
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
});
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('EHAM');
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
});
});
describe('queueLeg (v0.25.0, ISS-043)', () => {
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');
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
plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
});
it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => {
const inst = await freshDb('qleg-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await queueLeg(inst, 'plane-starter', 'EHAM');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // still on its active leg…
expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight
expect(JSON.parse(plane.queue)).toEqual(['EHAM']);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qleg-ghost.db');
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 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/);
});
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 expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 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
});
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dupflying.db');
await inst.db.update(schema.planes)
.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/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
});
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();
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
});
});
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 cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
});
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);
});
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);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qcancel-ghost.db');
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
});
});
@@ -291,6 +558,36 @@ describe('abandonPackage', () => {
});
});
describe('ignorePackage', () => {
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
const inst = await freshDb('ig-open.db');
await insertOpenPackage(inst, 'p-ig');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await ignorePackage(inst, 'p-ig');
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
expect(row.status).toBe('ignored');
expect(row.planeId).toBeNull(); // was never bound
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
});
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`ig-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
},
);
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ig-unknown.db');
await expectActionError(ignorePackage(inst, 'nope'), 404);
});
});
describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db');
@@ -325,6 +622,38 @@ describe('buyPlane', () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 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 });
const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72');
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);
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 });
const { planeId } = await buyPlane(inst, 'atr72');
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 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);
});
});
describe('sellPlane', () => {
@@ -357,6 +686,32 @@ describe('sellPlane', () => {
await expectActionError(sellPlane(inst, 'plane-extra'), 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,
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/);
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
});
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await queueLeg(inst, 'plane-extra', 'LEMD');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
});
it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
@@ -368,156 +723,81 @@ describe('sellPlane', () => {
});
});
describe('refuelPlane', () => {
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
const inst = await freshDb('refuel-ok.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
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 { cost } = await refuelPlane(inst, 'plane-starter');
const { cost } = await repairPlane(inst, 'plane-starter');
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100);
expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
});
it('is a free no-op when the tank is already full', async () => {
const inst = await freshDb('refuel-full.db'); // seeded starter is full
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 { cost } = await refuelPlane(inst, 'plane-starter');
const { cost } = await repairPlane(inst, 'plane-starter');
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);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('refuel-flying.db');
const inst = await freshDb('repair-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
});
it('rejects the fill-up when cash is below the cost with 409', async () => {
const inst = await freshDb('refuel-poor.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960
it('rejects a plane that is already repairing with 409', async () => {
const inst = await freshDb('repair-busy.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
await expectActionError(repairPlane(inst, 'plane-starter'), 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 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/);
// 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,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await expectActionError(sellPlane(inst, 'plane-starter'), 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 expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404);
});
// --- forFlight mode (v0.16.0) ---
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
});
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', 'plane-starter');
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
});
it('forFlight is a free no-op when the tank already covers the leg', async () => {
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
await loadPackage(inst, 'p-ff2', 'plane-starter');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
expect(cost).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
});
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
await insertOpenPackage(inst, 'p-x');
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
});
it('forFlight rejects an expired package with 409', async () => {
const inst = await freshDb('refuel-ff-expired.db');
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
});
it('forFlight rejects an unknown package id with 404', async () => {
const inst = await freshDb('refuel-ff-unknown.db');
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
});
it('forFlight rejects a missing packageId with 400', async () => {
const inst = await freshDb('refuel-ff-nopkg.db');
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
});
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
const inst = await freshDb('refuel-ff-nofit.db');
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
await loadPackage(inst, 'p-far', 'plane-starter');
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
409,
/does not fit/i,
);
const inst = await freshDb('repair-unknown.db');
await expectActionError(repairPlane(inst, 'ghost'), 404);
});
});
+190 -83
View File
@@ -3,24 +3,36 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { getStateSnapshot, parseIcaoQueue } from './state';
import {
FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT,
MAX_PLANE_DISCOUNT,
PLANE_DISCOUNT_PER_REP,
PLANE_TYPES,
REPAIR_COST,
REPAIR_DURATION_MS,
repRate,
SELL_FRACTION,
} from './catalog';
import { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed';
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
// load a package onto a grounded plane at its origin, fly to any airport, deliver
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
// load a package onto a grounded plane at its origin, fly to any airport (dispatch
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
// grounded; plus buy/sell planes. Each function validates and mutates ONE throwaway-safe GameDb instance
// (persisted at the end); routes wrap them in engine.runAction() so tick +
// validate + mutate are atomic under the sim lock. Validation failures throw
// GameActionError carrying the HTTP status:
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error {
@@ -43,8 +55,9 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
// --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
// cargo keeps occupying its space until delivered/abandoned).
// 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> {
const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
@@ -69,13 +82,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const occupied = (
await inst.db.select().from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
)
).length;
if (occupied >= type.capacity) {
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
const occupied = await inst.db.select({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
);
const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
if (usedKg + pkg.weightKg > type.maxPayloadKg) {
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
}
await inst.db.update(schema.packages)
@@ -89,7 +101,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
// loaded packages' destinations or pure repositioning. On arrival the engine
// delivers whatever is bound for that airport. Validation: plane 'available',
// destination in the airport dataset, enough fuel for the leg.
// destination in the airport dataset, enough fuel for the leg. ISS-082 (v0.42.0):
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
// 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> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
@@ -97,6 +114,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
}
if (plane.health <= 0) {
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
}
const fc = await loadAirportsGeoJson();
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
@@ -106,16 +126,39 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
// 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)}`);
}
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
if (requiredL > target) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} L`);
}
// runAction() ticked first, so this is current game time.
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
const queue = parseIcaoQueue(plane.queue);
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
await inst.db.update(schema.planes)
.set({
status: 'flying',
@@ -123,8 +166,72 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
departureSimTime: sim.clockMs,
lat: origin.lat,
lon: origin.lon,
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
queue: JSON.stringify(queue),
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
// slow to 1× instead of pausing), and restores the last manually selected speed when this
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
// + dispatch are atomic under the sim lock.
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
await inst.db.update(schema.simState).set(sets);
await persist(inst);
}
// --- Queue (POST /api/planes/:id/queue {destIcao}) — ISS-043 part 1 ---
// Append a NOT-yet-started leg to the plane's queue. Works whether the plane is
// grounded or flying (you can pre-build a route); nothing auto-starts here — the
// 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> {
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`);
const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
throw new GameActionError(400, `unknown airport ${destIcao}`);
}
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
// leg already in the queue would only produce pointless repeated hops on landing.
if (plane.status === 'flying' && plane.destIcao === destIcao) {
throw new GameActionError(409, `already flying to ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue);
if (queue.includes(destIcao)) {
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
}
queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst);
}
// --- Queue cancel (POST /api/planes/:id/queue/cancel {index}) — ISS-043 part 1 ---
// 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> {
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`);
const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) {
throw new GameActionError(400, `queue index ${index} out of range — the queue holds ${queue.length} legs`);
}
queue.splice(index, 1);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst);
}
@@ -162,18 +269,38 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
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 full price; it arrives 'available' with a full tank at
// the home base (EDDF).
// 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 }> {
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');
if (account.cash < type.price) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
// 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 fc = await loadAirportsGeoJson();
@@ -192,8 +319,9 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
lat: home.lat,
lon: home.lon,
departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
});
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
await persist(inst);
return { planeId };
}
@@ -210,6 +338,20 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
}
// v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay
// 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage).
const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
);
if (carrying.length > 0) {
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
}
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
if (parseIcaoQueue(plane.queue).length > 0) {
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
@@ -225,74 +367,39 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
return { payout };
}
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
// enough fuel for one loaded package's destination leg — required liters per the
// weighted burn model, independent of how much is already in the tank.
export interface RefuelOptions {
mode?: 'full' | 'forFlight';
packageId?: string;
}
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
// leg doesn't fit a full tank).
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> {
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
// 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 }> {
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`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
}
// Free no-op when the plane is already at full health — nothing to repair.
if (plane.health >= 100) return { cost: 0 };
const mode = opts.mode ?? 'full';
let targetFuel: number; // tank level after the refuel
if (mode === 'forFlight') {
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
}
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
}
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
targetFuel = Math.max(plane.fuel, requiredL);
} else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel;
}
const liters = Math.max(0, targetFuel - plane.fuel);
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
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 (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
if (account.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
}
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
// 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 persist(inst);
return { cost };
return { cost: REPAIR_COST };
}
// --- Route helper (response convention, v0.8.0) ---
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { STARTING_REPUTATION } from './seed';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
it('every entry has positive specs and a unique id', () => {
const ids = Object.keys(PLANE_TYPES);
expect(new Set(ids).size).toBe(ids.length);
for (const t of Object.values(PLANE_TYPES)) {
expect(t.id, `${t.name} id`).toBeTruthy();
expect(t.price, `${t.name} price`).toBeGreaterThan(0);
expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0);
expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0);
expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0);
expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0);
expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0);
}
});
it('atr72 is the starter model', () => {
expect(STARTER_MODEL).toBe('atr72');
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
});
});
// v0.38.0 (ISS-072) — the shared rep-discount shape: linear above the starting value, capped.
describe('repRate', () => {
it('is exactly 0 at and below the starting reputation', () => {
expect(repRate(STARTING_REPUTATION, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
expect(repRate(0, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
expect(repRate(-5, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(0); // below-start clamps too
});
it('is linear in reputation above the starting value (exact mid-range values)', () => {
expect(repRate(STARTING_REPUTATION + 10, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.1, 12); // 0.01 × 10
expect(repRate(STARTING_REPUTATION + 30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.3, 12); // 0.01 × 30
expect(repRate(STARTING_REPUTATION + 40, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBeCloseTo(0.2, 12); // 0.005 × 40 = cap
});
it('clamps at the cap', () => {
expect(repRate(STARTING_REPUTATION + 50, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(MAX_PLANE_DISCOUNT); // 0.5 → capped to 0.4
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
});
});
+97 -7
View File
@@ -1,5 +1,9 @@
import { STARTING_REPUTATION } from './seed';
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
// ponytail: circular import with seed.ts is safe — neither module touches the other's
// bindings at top level, only inside function bodies.
export interface PlaneType {
id: string;
@@ -16,8 +20,10 @@ export interface PlaneType {
// Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price.
price: number;
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane.
capacity: number;
// Max payload in kg (v0.27.0, ISS-045 — replaces the v0.12.0 slot count): total
// package weight that can be loaded on one plane. ATR 72-600-class figure, tuned
// so every cargo type at max count × 1.3 jitter still fits alone (see UNIT_WEIGHT_KG).
maxPayloadKg: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
@@ -31,22 +37,105 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
// a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000,
capacity: 6,
// ponytail: balance-tunable payload (kg) — every cargo type at max count × 1.3 jitter
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
maxPayloadKg: 4_800,
},
// Progression ladder above the ATR starter (v0.28.0, ISS-047): bigger tanks, faster airframes,
// higher prices — everything downstream is data-driven via PLANE_TYPES[model]. Values are
// balance-tunable; keep maxPayloadKg ≥ every cargo type at max count × 1.3 jitter so any
// single package still fits alone (same rule as the ATR above).
crj900: {
id: 'crj900',
name: 'CRJ-900',
maxFuel: 8_000,
burnPerKm: 2.1,
emptyWeightKg: 17_500,
speedKmh: 830,
price: 450_000,
maxPayloadKg: 6_500,
},
e175: {
id: 'e175',
name: 'Embraer E175',
maxFuel: 9_500,
burnPerKm: 2.0,
emptyWeightKg: 23_000,
speedKmh: 840,
price: 900_000,
maxPayloadKg: 9_000,
},
a320: {
id: 'a320',
name: 'Airbus A320-200',
maxFuel: 12_000,
burnPerKm: 2.2,
emptyWeightKg: 41_000,
speedKmh: 845,
price: 1_800_000,
maxPayloadKg: 18_000,
},
a330: {
id: 'a330',
name: 'Airbus A330-200',
maxFuel: 20_000,
burnPerKm: 2.16,
emptyWeightKg: 95_000,
speedKmh: 910,
price: 4_500_000,
maxPayloadKg: 48_000,
},
b777: {
id: 'b777',
name: 'Boeing 777-300ER',
maxFuel: 30_000,
burnPerKm: 2.5,
emptyWeightKg: 130_000,
speedKmh: 920,
price: 9_000_000,
maxPayloadKg: 80_000,
},
};
// The plane the player starts with.
export const STARTER_MODEL = 'atr72';
// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this.
// Jet-A1 fuel price in €/liter (v0.8.0): top-up cost = liters × this — applied to the automatic
// dispatch top-ups since ISS-082 (manual refuel removed).
export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5;
// Shared rep-discount shape (v0.38.0, ISS-072): linear in reputation above the starting value,
// capped — exactly 0 at/below STARTING_REPUTATION so default-rep behavior is unchanged.
export function repRate(reputation: number, perPoint: number, cap: number): number {
return Math.min(cap, perPoint * Math.max(0, reputation - STARTING_REPUTATION));
}
// ponytail: balance-tunable — plane purchase discount per rep point above the starting value.
export const PLANE_DISCOUNT_PER_REP = 0.01;
// ponytail: balance-tunable — max plane purchase discount (reached at rep ≥ 50).
export const MAX_PLANE_DISCOUNT = 0.4;
// ponytail: balance-tunable — refuel price discount per rep point above the starting value.
export const FUEL_DISCOUNT_PER_REP = 0.005;
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
export const MAX_FUEL_DISCOUNT = 0.2;
// --- Maintenance (v0.39.0, ISS-069) ---
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
export const WEAR_PER_KM = 0.0005;
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
export const REPAIR_COST = 1_500;
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
export const REPAIR_DURATION_MS = 3 * 86_400_000;
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
// id used by buy/dispatch, `label` the display name — clients compute all market
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
// previews (buy price, sell payout) from these + the economy constants.
export interface CatalogEntry {
model: string;
label: string;
@@ -57,7 +146,8 @@ export interface CatalogEntry {
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
emptyWeightKg: number;
speedKmh: number;
capacity: number;
// Max payload in kg (v0.27.0) — total package weight that can be loaded on one plane.
maxPayloadKg: number;
}
// The catalog as a flat array, one entry per plane type — the single source of
@@ -71,6 +161,6 @@ export function catalogEntries(): CatalogEntry[] {
burnPerKm: t.burnPerKm,
emptyWeightKg: t.emptyWeightKg,
speedKmh: t.speedKmh,
capacity: t.capacity,
maxPayloadKg: t.maxPayloadKg,
}));
}
+239 -18
View File
@@ -1,18 +1,25 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials';
import {
BASE_REWARD,
buildSeed,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
rewardForDistanceKm,
rewardFor,
STARTING_CASH,
STARTING_REPUTATION,
type Rng,
type SeedAirport,
} from './seed';
@@ -44,6 +51,8 @@ describe('buildSeed', () => {
destIcao: null,
lat: 50.0333,
lon: 8.5706,
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
health: 100, // v0.39.0 (ISS-069) — full health on first run
});
});
@@ -69,28 +78,45 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}
});
it('sets deadlines inside the 6–72h game-time window after now', () => {
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
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);
}
}
});
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
}
});
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -105,9 +131,12 @@ describe('buildSeed', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
it('rewardForDistanceKm applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
expect(rewardFor(1000, 500)).toBe(4_000);
expect(rewardFor(1000, 4_000)).toBe(11_000);
});
});
@@ -126,11 +155,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
// Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
@@ -172,7 +204,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -188,7 +222,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -211,4 +247,189 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(a).toEqual(b);
});
});
describe('forced weight (v0.27.0 e2e hook)', () => {
it('weightKg forces the package weight and feeds the payout', () => {
const c = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(c.weightKg).toBe(1_234);
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
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 });
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 });
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
describe('special status (v0.37.0, ISS-073)', () => {
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
});
it('applies the registry reward multiplier to the base reward', () => {
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
});
it('scales the drawn deadline window by the registry factor', () => {
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
});
it('a forced deadline offset skips the special scaling', () => {
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
});
it('a forced special (including explicit null) skips the appended RNG draw', () => {
const countDraws = (special?: string | null): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
return n;
};
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
expect(countDraws('priority')).toBe(6);
expect(countDraws(null)).toBe(6); // explicit null skips too
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
});
});
describe('reputation job bonus (v0.38.0, ISS-072)', () => {
it('multiplies the reward by 1 + repRate at elevated reputation', () => {
const c = generatePackage({ id: 'c-rep', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: null, reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)).toBeCloseTo(0.5, 12); // rep 60 → ×1.5
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS))));
});
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 });
expect(a).toEqual(b); // no bonus at the starting value
});
it('composes with the special multiplier (base × jobBonus × special)', () => {
const c = generatePackage({ id: 'c-rep-sp', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority', reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(
Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * SPECIALS.priority.rewardMultiplier),
);
});
it('consumes no extra RNG draw', () => {
const countDraws = (reputation?: number): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-rep-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special: null, reputation });
return n;
};
expect(countDraws()).toBe(6); // forced-null special → the fixed order minus the appended draw
expect(countDraws(60)).toBe(6); // the rep hook consumes no draws
});
});
});
describe('distance cap (v0.44.0, ISS-079)', () => {
// A and B are ~90 km apart; C is ~19000 km from both — every pair touching C exceeds the cap.
const FAR: SeedAirport[] = [
{ icao: 'AAAA', name: 'Close One', lat: 50, lon: 8 },
{ icao: 'BBBB', name: 'Close Two', lat: 50.5, lon: 9 },
{ icao: 'CCCC', name: 'Far Away', lat: -35, lon: 175 },
];
// Scripted RNG: each value v yields index floor(v * FAR.length) — 0.x → AAAAA/BBBBB range.
const scripted = (values: number[]): { rng: Rng; draws: () => number } => {
let i = 0;
return { rng: () => values[Math.min(i++, values.length - 1)], draws: () => i };
};
it('re-picks an over-cap pair until the leg fits (both endpoints unforced)', () => {
// Attempt 1: C→A (over cap) → rejected; attempt 2: A→B (~90 km) → accepted. Exactly 4 endpoint draws.
const { rng, draws } = scripted([0.9, 0.1, 0.1, 0.4]);
const c = generatePackage({ id: 'c-cap', airports: FAR, rng, nowMs: NOW, special: null });
expect(c.originIcao).toBe('AAAA');
expect(c.destIcao).toBe('BBBB');
// 4 endpoint draws (origin + dest per attempt — the accepted pair stops the loop) + 4 fixed draws.
expect(draws()).toBe(8);
});
it('falls back to a within-cap pick of the last origin when attempts exhaust', () => {
// 24 over-cap C→A attempts, then A→C (still over cap) ends the bounded loop; the fallback keeps
// origin A and draws one index from its within-cap neighbours [B] → A→B. 25×2 + 1 = 51 draws.
const values: number[] = [];
for (let i = 0; i < 24; i++) values.push(0.9, 0.1); // C→A ×24
values.push(0.1, 0.9); // A→C — attempt 25, still over cap
values.push(0.5); // fallback draw → candidates[0] = B
const { rng, draws } = scripted(values);
const c = generatePackage({ id: 'c-cap-fb', airports: FAR, rng, nowMs: NOW, special: null });
expect(c.originIcao).toBe('AAAA');
expect(c.destIcao).toBe('BBBB');
// 51 endpoint draws (25 attempts × 2 + the fallback draw) + 4 fixed draws.
expect(draws()).toBe(55);
});
it('keeps the last over-cap pair when the origin has no within-cap neighbour (no throw mid-tick)', () => {
// All 25 attempts C→A; the fallback finds no airport within cap of C → -1, pair kept. No extra draw.
const values: number[] = [];
for (let i = 0; i < 25; i++) values.push(0.9, 0.1); // C→A ×25
const { rng, draws } = scripted(values);
const c = generatePackage({ id: 'c-cap-keep', airports: FAR, rng, nowMs: NOW, special: null });
expect(c.originIcao).toBe('CCCC');
expect(c.destIcao).toBe('AAAA');
// 50 endpoint draws (25 attempts × 2) + no fallback draw when there are no candidates + 4 fixed.
expect(draws()).toBe(54);
});
it('leaves forced endpoints uncapped and consumes no extra draws (the UNNT→LPPD backstop relies on this)', () => {
const c = generatePackage({ id: 'c-cap-forced', airports: FAR, rng: mulberry32(42), nowMs: NOW, originIcao: 'CCCC', destIcao: 'AAAA' });
expect(c.originIcao).toBe('CCCC'); // ~19000 km — forced pairs skip the cap entirely
expect(c.destIcao).toBe('AAAA');
const countDraws = (origin?: string, dest?: string): number => {
let n = 0;
generatePackage({ id: 'c-cap-n', airports: FAR, rng: () => { n += 1; return mulberry32(42)(); }, nowMs: NOW, originIcao: origin, destIcao: dest, special: null });
return n;
};
expect(countDraws('CCCC')).toBe(5); // forced origin + random (uncapped) dest → 1 endpoint draw + 4 fixed
expect(countDraws('CCCC', 'AAAA')).toBe(4); // both forced → no endpoint draws at all
});
it('pickDestWithinCap picks uniformly from within-cap neighbours, excludes the origin, and returns -1 without a draw when none fit', () => {
const near: SeedAirport[] = [FAR[0], FAR[1]]; // A + B only — both within cap of each other
let calls = 0;
const countingRng: Rng = () => { calls += 1; return 0.9; };
expect(pickDestWithinCap(near, 0, countingRng)).toBe(1); // A's only neighbour is B
expect(calls).toBe(1);
let farCalls = 0;
const farRng: Rng = () => { farCalls += 1; return 0.9; };
expect(pickDestWithinCap(FAR, 2, farRng)).toBe(-1); // C has no neighbour within the cap
expect(farCalls).toBe(0); // -1 path consumes no draw
});
it('every generated leg on the real dataset stays under the cap', async () => {
const { loadAirportsGeoJson } = await import('@/lib/airports');
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
for (let seed = 1; seed <= 50; seed++) {
const c = generatePackage({ id: `c-real-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW });
const o = airports.find((a) => a.icao === c.originIcao)!;
const d = airports.find((a) => a.icao === c.destIcao)!;
expect(haversineKm(o.lat, o.lon, d.lat, d.lon)).toBeLessThanOrEqual(MAX_JOB_LEG_KM);
}
});
});
+121 -14
View File
@@ -1,5 +1,6 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never
@@ -38,6 +39,11 @@ export interface SeedPlane {
destIcao: null;
lat: number;
lon: number;
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
// column as JSON text on insert; defaults to an empty queue.
queue?: string[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
health: number;
}
export interface SeedPackage {
@@ -47,6 +53,10 @@ export interface SeedPackage {
reward: number;
cargoType: string;
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number;
status: 'open';
planeId: null;
@@ -57,7 +67,7 @@ export interface SeedData {
plane: SeedPlane;
packages: SeedPackage[];
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number };
}
// --- Tunables (documented so future revenue/cost work can build on them) ---
@@ -77,10 +87,32 @@ export const PACKAGE_COUNT_MAX = 12; // inclusive
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 2.0;
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value.
// ponytail: balance-tunable — job reward bonus per rep point above the starting value.
export const JOB_BONUS_PER_REP = 0.01;
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
export const MAX_JOB_BONUS = 1.0;
// v0.44.0 (ISS-079): generated jobs are capped at this great-circle distance so every spawned
// leg is flyable by at least one catalog plane even in worst-case headwind — the b777's full-tank
// range is ~13045 km nominal and ~10871 km with the +20% wind factor (maxRangeKm ÷ 1.2), so 9000
// leaves margin below it while still spanning most of a continent. ponytail: balance-tunable —
// must stay ≤ maxRangeKm(b777) / 1.2 or some generated legs are permanently unflyable.
export const MAX_JOB_LEG_KM = 9000;
// Bounded rejection attempts in generatePackage before the within-cap fallback kicks in.
const LEG_CAP_MAX_ATTEMPTS = 25;
// Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
@@ -88,19 +120,38 @@ const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
// type at MAX count × max jitter (1.3×) still fits the ATR 72-600's payload alone, and
// typical packages land ~400–2500 kg — a normal flight carries roughly 3–6 by weight.
export const UNIT_WEIGHT_KG: Record<string, number> = {
Passengers: 50, // max 50 × 68 × 1.3 = 4420 < 4800
Mail: 60, // max 60 × 50 × 1.3 = 3900
Perishables: 70, // max 70 × 50 × 1.3 = 4550
Electronics: 40, // max 40 × 50 × 1.3 = 2600
'Auto parts': 72, // max 72 × 50 × 1.3 = 4680
};
export function rewardFor(km: number, weightKg: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg);
}
// Shared package generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
// distinct airports from the dataset, distance-based reward, cargo flavor,
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
// the e2e test hook to force a near-term expiry). The RNG call order per package
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
// distinct airports from the dataset, distance+weight-based reward, cargo flavor,
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// force a near-term expiry). The RNG call order per package is fixed: origin,
// dest, cargoType, cargoCount, weight jitter, [deadline window], [special].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
// jitter draw, same pattern as forced endpoints.
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
// starting value so seed-time behavior is byte-identical.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -109,6 +160,9 @@ export function generatePackage(input: {
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
weightKg?: number;
special?: string | null;
reputation?: number;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -130,6 +184,27 @@ export function generatePackage(input: {
} else {
di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
// v0.44.0 (ISS-079): cap on generated legs — when BOTH endpoints are unforced, rejection-sample
// the pair until it fits MAX_JOB_LEG_KM so spawned jobs stay flyable by at least one catalog plane.
// Forced paths keep their pre-v0.44.0 draw behavior exactly (the engine's coverage spawn caps its
// random dest itself via pickDestWithinCap). Under-cap pairs take just the two draws above, so the
// RNG stream is byte-identical to before for them.
if (input.originIcao === undefined) {
let km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
for (let attempt = 1; km > MAX_JOB_LEG_KM && attempt < LEG_CAP_MAX_ATTEMPTS; attempt++) {
oi = Math.floor(rng() * airports.length);
di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
}
if (km > MAX_JOB_LEG_KM) {
// Exhausted: keep the last origin and pick uniformly from its within-cap neighbours. If none
// exist (impossible in the curated dataset — every airport has a neighbour well inside the cap),
// keep the over-cap pair rather than throw mid-tick.
const fallback = pickDestWithinCap(airports, oi, rng);
if (fallback >= 0) di = fallback;
}
}
}
if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
const origin = airports[oi];
@@ -140,16 +215,31 @@ export function generatePackage(input: {
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
const deadlineMs =
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// 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()));
// 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;
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
// forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
return {
id: input.id,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardForDistanceKm(km),
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType,
cargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
@@ -157,6 +247,19 @@ export function generatePackage(input: {
};
}
// 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.
export function pickDestWithinCap(airports: SeedAirport[], originIdx: number, rng: Rng): number {
const candidates: number[] = [];
for (let j = 0; j < airports.length; j++) {
if (j === originIdx) continue;
if (haversineKm(airports[originIdx].lat, airports[originIdx].lon, airports[j].lat, airports[j].lon) <= MAX_JOB_LEG_KM) candidates.push(j);
}
if (candidates.length === 0) return -1;
return candidates[Math.floor(rng() * candidates.length)];
}
function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
}
@@ -182,6 +285,8 @@ export function buildSeed(input: {
destIcao: null,
lat: hub.lat,
lon: hub.lon,
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));
@@ -198,6 +303,8 @@ export function buildSeed(input: {
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
// by ~0 instead of treating "time since install" as elapsed play time.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs },
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
// selected manually yet, so resume would restore 1× anyway.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
};
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { mulberry32, type Rng } from './seed';
import { pickSpecial, SPECIALS, SPECIAL_SPAWN_CHANCE } from './specials';
describe('pickSpecial (v0.37.0, ISS-073)', () => {
it('returns null when the chance draw fails and consumes exactly one draw', () => {
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.9; }; // ≥ SPECIAL_SPAWN_CHANCE → no special
expect(pickSpecial(rng)).toBeNull();
expect(draws).toBe(1);
});
it('picks among the registry keys when the chance draw passes', () => {
const seen = new Set<string>();
for (let i = 0; i < 2_000; i++) {
let call = 0;
// First call is the chance draw (< chance → roll); later calls sweep [0, 1) as index draws.
const rng: Rng = () => (call++ === 0 ? 0.1 : i / 2_000);
const s = pickSpecial(rng);
if (s !== null) seen.add(s);
}
expect([...seen].sort()).toEqual(Object.keys(SPECIALS).sort()); // every key reachable
});
it('never returns a status whose entry was removed; an empty registry consumes no draws', () => {
const withoutPriority = Object.fromEntries(Object.entries(SPECIALS).filter(([k]) => k !== 'priority'));
for (let i = 0; i < 500; i++) {
expect(pickSpecial(mulberry32(i), withoutPriority)).not.toBe('priority'); // removing an entry disables it
}
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.0; }; // would pass the chance draw if consulted
expect(pickSpecial(rng, {})).toBeNull();
expect(draws).toBe(0);
});
it('rolls a special for roughly SPECIAL_SPAWN_CHANCE of packages (fixed RNG)', () => {
const rng = mulberry32(42);
let hits = 0;
for (let i = 0; i < 10_000; i++) if (pickSpecial(rng) !== null) hits++;
expect(hits / 10_000).toBeGreaterThan(SPECIAL_SPAWN_CHANCE - 0.05);
expect(hits / 10_000).toBeLessThan(SPECIAL_SPAWN_CHANCE + 0.05);
});
});
+34
View File
@@ -0,0 +1,34 @@
// Special-status registry (v0.37.0, ISS-073) — data-driven package modifiers: one entry per
// special status maps to reward/deadline/reputation tweaks applied at generation and delivery.
// Adding or removing a special is an edit of ONE entry here — no scattered branches elsewhere.
import type { Rng } from './seed';
export interface SpecialModifiers {
// Multiplier on the base reward (applied at package generation).
rewardMultiplier: number;
// Scale factor on the drawn deadline window (applied at generation; forced offsets skip it).
deadlineScale: number;
// Flat reputation bonus added to the distance-based gain at delivery.
repBonus: number;
}
export const SPECIALS: Record<string, SpecialModifiers> = {
priority: {
rewardMultiplier: 1.5, // ponytail: balance-tunable — a 50% premium for the tighter deadline below
deadlineScale: 0.5, // ponytail: balance-tunable — half the normal 6–72h window (3–36h)
repBonus: 0.5, // ponytail: balance-tunable — flat rep on top of the distance-based gain at delivery
},
};
// Fraction of generated packages that roll a special status (v0.37.0).
export const SPECIAL_SPAWN_CHANCE = 0.15;
// Pure spawn helper: one chance draw, then a uniform pick among the registry's keys. The registry
// is a PARAM defaulting to the real one so tests can pass a filtered/empty record — removing an
// entry cleanly disables that status (an empty registry consumes no draws at all).
export function pickSpecial(rng: Rng, registry: Record<string, SpecialModifiers> = SPECIALS): string | null {
const keys = Object.keys(registry);
if (keys.length === 0) return null;
if (rng() >= SPECIAL_SPAWN_CHANCE) return null;
return keys[Math.floor(rng() * keys.length)];
}
+58 -6
View File
@@ -1,16 +1,18 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
export interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
status: 'available' | 'flying' | 'repairing';
fuel: number;
maxFuel: number;
locationIcao: string;
@@ -24,6 +26,27 @@ export interface PlaneState {
departureSimTime: number | null;
lat: number;
lon: number;
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
queue: string[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
// plane is broken and cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is
// also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time.
health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null;
}
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
export function parseIcaoQueue(raw: string | null): string[] {
if (!raw) return [];
try {
const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
export interface PackageState {
@@ -35,8 +58,15 @@ export interface PackageState {
reward: number;
cargoType: string;
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
createdSimTime: number;
}
@@ -47,7 +77,7 @@ export interface StateSnapshot {
planes: PlaneState[];
packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint.
// the client can compute buy/sell previews without a separate endpoint.
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
@@ -72,6 +102,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
// v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing.
const legDistanceKm = (originIcao: string, destIcao: string) => {
const from = coordsByIcao.get(originIcao);
const to = coordsByIcao.get(destIcao);
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
};
// 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.
@@ -84,7 +120,17 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
};
return {
@@ -108,6 +154,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon,
queue: parseIcaoQueue(p.queue),
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
repairUntilSimTime: p.repairUntilSimTime,
};
}),
packages: packageRows.map((p) => ({
@@ -119,6 +168,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
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,
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
+162 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { StateSnapshot } from './game/state';
import { planesGeoJson, routesGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
import { haversineKm } from './geo';
const AIRPORTS = new Map<string, AirportCoord>([
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
planes: [],
packages: [],
catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
],
fuelPricePerL: 0.8,
sellFraction: 0.5,
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
snapshot({
catalog: [],
planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 },
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -140,6 +140,157 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
});
});
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => {
it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => {
const fc = queuedRoutesGeoJson(
[
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
const coords = fc.features[0].geometry.coordinates;
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // one arc, exact endpoints
expect(coords[0]).toEqual([8.56, 50.03]); // starts at the plane's airport (EDDF)
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // ends at the queued airport (EHAM)
});
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('draws nothing for a flying plane with an empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => {
const fc = queuedRoutesGeoJson(
[
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
const coords = fc.features[0].geometry.coordinates;
// Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points.
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1);
expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin
expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD)
expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact
});
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
],
AIRPORTS,
);
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1', 'p3']); // p2 has an empty queue
const p3 = fc.features[1].geometry.coordinates;
expect(p3[0]).toEqual([9.68, 52.46]); // grounded chain starts at the plane's airport (EDDV)
expect(p3[p3.length - 1]).toEqual([-3.56, 40.47]); // …and ends at the queued airport (LEMD)
});
});
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
it('returns an empty collection for the no-hover "" sentinel', () => {
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
it('draws one great-circle line between two known airports with exact endpoints', () => {
const fc = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
const coords = fc.features[0].geometry.coordinates;
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
});
it('draws the same trace reversed (endpoints swapped, arc mirrored)', () => {
const ab = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS).features[0].geometry.coordinates;
const ba = hoverRouteGeoJson('EHAM', 'EDDF', AIRPORTS).features[0].geometry.coordinates;
expect(ba.length).toBe(ab.length);
// The slerp is symmetric up to floating-point rounding (1 − t ≠ (N−t)/N bit-exactly), so
// compare with a tolerance: each reversed point sits on the forward arc within ~1e-9 degrees.
for (let i = 0; i < ab.length; i++) {
expect(Math.abs(ba[i][0] - ab[ab.length - 1 - i][0])).toBeLessThan(1e-9);
expect(Math.abs(ba[i][1] - ab[ab.length - 1 - i][1])).toBeLessThan(1e-9);
}
});
it('returns an empty collection when the from airport is missing from the dataset', () => {
expect(hoverRouteGeoJson('ZZZZ', 'EDDV', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
it('returns an empty collection when the to airport is missing from the dataset', () => {
expect(hoverRouteGeoJson('EDDF', 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
});
describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => {
it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => {
const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport
const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
const coords = fc.features[0].geometry.coordinates;
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
});
it('returns an empty collection when the dest airport is missing from the dataset', () => {
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
// ISS-077 (v0.41.0): a flying carrier's trace starts at its landing airport — when that IS the
// package's destination there is nothing to draw (the solid active-leg layer covers it).
it('returns an empty collection when the start point equals the dest airport', () => {
expect(hoverTraceGeoJson(AIRPORTS.get('EHAM')!, 'EHAM', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
});
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
it('samples >2 points with the endpoints exactly at the airport coords', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
+94 -2
View File
@@ -1,4 +1,4 @@
import type { StateSnapshot } from './game/state';
import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format';
import { positionAt } from './sim/movement';
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
export interface PlaneFeatureProps {
id: string;
modelLabel: string;
status: 'available' | 'flying';
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
status: 'available' | 'flying' | 'repairing';
fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded
flying: boolean;
@@ -85,6 +86,63 @@ export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, n
return coords;
}
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
// airports, drawn top-most while a panel row hovers (job-list rows, on-board cargo rows, the
// Relocate section). Empty collection when either endpoint is missing from the dataset — never
// guessed; the '' sentinel (no hovered target) resolves to nothing and yields the same.
export function hoverRouteGeoJson(
fromIcao: string,
toIcao: string,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const from = airportsByIcao.get(fromIcao);
const to = airportsByIcao.get(toIcao);
if (!from || !to) return EMPTY_LINES;
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: '' }, // no owning plane — a pure hover overlay
},
],
};
}
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
// collection when the destination is missing from the dataset — never guessed. ISS-077 (v0.41.0):
// also empty when the start point IS the destination airport itself (a flying carrier's trace now
// starts at its landing airport, which can equal the package's dest) — nothing to draw there; the
// solid active-leg layer covers that case.
export function hoverTraceGeoJson(
from: AirportCoord,
toIcao: string,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const to = airportsByIcao.get(toIcao);
if (!to) return EMPTY_LINES;
if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: '' }, // no owning plane — a pure hover overlay
},
],
};
}
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
// Grounded planes draw nothing; a flying plane missing either endpoint from the
// dataset is skipped, never guessed. The line is sampled along the great circle
@@ -111,3 +169,37 @@ export function routesGeoJson(
}
return { type: 'FeatureCollection', features };
}
// ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue,
// chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination
// (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer),
// and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current
// airport (locationIcao → queue[0] → …). 'repairing' planes draw nothing — they can't fly. Arcs are
// concatenated with the shared joint point dropped once. A plane is skipped — never guessed — when
// its first point or any queued ICAO is missing from the airport index; empty collection when
// nothing qualifies.
export function queuedRoutesGeoJson(
planes: PlaneState[],
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const features: MapLineFeature[] = [];
for (const p of planes) {
if (p.queue.length === 0) continue;
// First point of the chain: a flying plane's landing airport, a grounded one's current airport.
const firstIcao = p.status === 'flying' ? p.destIcao : p.status === 'available' ? p.locationIcao : null;
if (!firstIcao) continue;
const pts = [firstIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
const coords: [number, number][] = [];
for (let i = 0; i < pts.length - 1; i++) {
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
}
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };
}
+39 -21
View File
@@ -3,15 +3,18 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/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}/refuel',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
@@ -92,18 +95,17 @@ describe('OpenAPI spec', () => {
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm',
'capacity',
'emptyWeightKg', // v0.16.0 weighted burn model input
'label',
'maxFuel',
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
'model',
'price',
'speedKmh',
]);
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'maxPayloadKg', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
@@ -113,6 +115,7 @@ describe('OpenAPI spec', () => {
'destIcao',
'destName',
'fuel',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'id',
'lat',
'locationIcao',
@@ -120,6 +123,8 @@ describe('OpenAPI spec', () => {
'lon',
'maxFuel',
'model',
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'status',
]);
@@ -131,19 +136,22 @@ describe('OpenAPI spec', () => {
'deadlineSimTime',
'destIcao',
'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'id',
'originIcao',
'originName',
'planeId',
'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
});
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
@@ -169,7 +177,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => {
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
@@ -183,9 +191,11 @@ describe('OpenAPI spec', () => {
'/api/packages/{id}/load',
'/api/packages/{id}/abandon',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/refuel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
]) {
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
@@ -196,18 +206,25 @@ describe('OpenAPI spec', () => {
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/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
// 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(
'#/components/schemas/LoadPackageRequest',
);
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/FlyPlaneRequest',
);
expect(post('/api/planes/{planeId}/queue').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/QueueLegRequest', // v0.25.0 (ISS-043)
);
expect(post('/api/planes/{planeId}/queue/cancel').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/CancelQueuedLegRequest', // v0.25.0 (ISS-043)
);
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/BuyPlaneRequest',
);
@@ -215,16 +232,16 @@ describe('OpenAPI spec', () => {
'#/components/schemas/SellPlaneRequest',
);
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
// Request schema shapes.
const load = openApiSpec.components.schemas.LoadPackageRequest;
@@ -233,17 +250,18 @@ describe('OpenAPI spec', () => {
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
expect(fly.required).toEqual(['destIcao']);
expect(fly.properties.destIcao.type).toBe('string');
// v0.25.0 (ISS-043): the queue request schemas mirror fly's destIcao shape.
const queueLeg = openApiSpec.components.schemas.QueueLegRequest;
expect(queueLeg.required).toEqual(['destIcao']);
expect(queueLeg.properties.destIcao.type).toBe('string');
const cancelQueuedLeg = openApiSpec.components.schemas.CancelQueuedLegRequest;
expect(cancelQueuedLeg.required).toEqual(['index']);
expect(cancelQueuedLeg.properties.index.type).toBe('integer');
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
expect(buy.required).toEqual(['model']);
expect(buy.properties.model.type).toBe('string');
const sell = openApiSpec.components.schemas.SellPlaneRequest;
expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string');
// RefuelRequest (v0.16.0): both fields optional — no body / {} means "full".
const refuel = openApiSpec.components.schemas.RefuelRequest;
expect((refuel as { required?: string[] }).required ?? []).toEqual([]);
expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']);
expect(refuel.properties.packageId.type).toBe('string');
});
});
+162 -54
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'Flight Ops API',
description:
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell / refuel), 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), 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,
},
@@ -17,13 +17,13 @@ export const openApiSpec = {
paths: {
'/api/airports': {
get: {
summary: 'List EU airports',
summary: 'List world airports',
description:
'All fixed-wing EU airports (large, medium and seaplane bases) as a GeoJSON FeatureCollection.',
'The curated world airport dataset — one marker per metro area (large airports only, close clusters merged to the biggest; see scripts/build-world-airports.mjs) — as a GeoJSON FeatureCollection.',
operationId: 'listAirports',
responses: {
200: {
description: 'GeoJSON FeatureCollection of EU airports',
description: 'GeoJSON FeatureCollection of world airports',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AirportFeatureCollection' },
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
operationId: 'getState',
responses: {
200: {
@@ -106,7 +106,7 @@ export const openApiSpec = {
post: {
summary: 'Load a package onto a plane',
description:
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.',
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs enough payload room for the package weight — maxPayloadKg per catalog model; expired packages still occupy theirs (v0.27.0). The sim clock is ticked first, so the returned snapshot reflects current game time.',
operationId: 'loadPackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
@@ -165,11 +165,36 @@ 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',
description:
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there.',
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there. ISS-082 (v0.42.0): dispatch auto-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required)) at fuelPricePerL per liter minus a reputation-scaled discount, deducted from cash; no separate refuel endpoint exists anymore.',
operationId: 'flyPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -198,7 +223,80 @@ export const openApiSpec = {
},
409: {
description:
'Plane not grounded, or insufficient fuel for the leg (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty)',
'Plane not grounded, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/queue': {
post: {
summary: 'Queue a flight leg for a plane',
description:
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later dispatch.',
operationId: 'queueLeg',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/QueueLegRequest' },
},
},
},
responses: {
200: {
description: 'Queued — full state snapshot (plane.queue now includes the new leg)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
400: {
description:
'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' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description:
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/queue/cancel': {
post: {
summary: 'Cancel a queued flight leg',
description:
'Remove a NOT-yet-started leg from the plane\'s queue by index (v0.25.0, ISS-043). Only queued legs are cancellable — the active in-flight leg (destIcao) is never affected.',
operationId: 'cancelQueuedLeg',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CancelQueuedLegRequest' },
},
},
},
responses: {
200: {
description: 'Cancelled — full state snapshot (plane.queue without the removed leg)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
400: {
description:
'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' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
@@ -208,7 +306,7 @@ export const openApiSpec = {
post: {
summary: 'Buy a plane from the market',
description:
'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
'Buy an aircraft at its catalog price minus a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation buys cheaper; no discount at the starting reputation). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
operationId: 'buyPlane',
requestBody: {
required: true,
@@ -268,39 +366,26 @@ export const openApiSpec = {
},
},
},
'/api/planes/{planeId}/refuel': {
'/api/planes/{planeId}/repair': {
post: {
summary: 'Refuel a grounded plane',
summary: 'Repair a grounded plane',
description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.',
operationId: 'refuelPlane',
'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
operationId: 'repairPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/RefuelRequest' },
},
},
},
responses: {
200: {
description: 'Refueled — full state snapshot (fuel raised by exactly the added liters, cash reduced by the cost)',
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
400: {
description: 'Invalid body (not an object, unknown field, unknown mode, or forFlight without packageId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id (or unknown package id in forFlight mode)',
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description:
'Plane is flying, the package is not loaded on this plane (or is expired), the leg does not fit a full tank, or cash is insufficient for the refuel',
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' } } },
},
},
@@ -378,7 +463,7 @@ export const openApiSpec = {
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' },
},
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
},
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
@@ -388,20 +473,20 @@ export const openApiSpec = {
properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
price: { type: 'number', description: 'Market price in € — the full catalog list price. Buying costs this minus a reputation-scaled discount at purchase time (v0.38.0); selling pays out sellFraction × this.' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
},
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'],
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'maxPayloadKg'],
},
AccountState: {
type: 'object',
properties: {
cash: { type: 'number' },
reputation: { type: 'number' },
reputation: { type: 'number', description: 'Reputation — grows with delivered packages; drives the purchase/fuel discounts and job reward quality (v0.38.0, ISS-072).' },
},
required: ['cash', 'reputation'],
},
@@ -430,7 +515,7 @@ export const openApiSpec = {
LoadPackageRequest: {
type: 'object',
properties: {
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' },
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with enough payload room for it (v0.27.0).' },
},
required: ['planeId'],
},
@@ -441,6 +526,20 @@ export const openApiSpec = {
},
required: ['destIcao'],
},
QueueLegRequest: {
type: 'object',
properties: {
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — appended to the queue as a not-yet-started leg (v0.25.0).' },
},
required: ['destIcao'],
},
CancelQueuedLegRequest: {
type: 'object',
properties: {
index: { type: 'integer', description: 'Zero-based position of the queued (not-yet-started) leg to remove; must be within [0, queue.length) (v0.25.0).' },
},
required: ['index'],
},
BuyPlaneRequest: {
type: 'object',
properties: {
@@ -455,22 +554,6 @@ export const openApiSpec = {
},
required: ['planeId'],
},
RefuelRequest: {
type: 'object',
description:
'Optional refuel body (v0.16.0) — omit it entirely or send {} to fill the tank; both fields are optional.',
properties: {
mode: {
enum: ['full', 'forFlight'],
description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
},
packageId: {
type: 'string',
description: 'Required with mode "forFlight": a package LOADED on this plane — its destIcao is the refuel target.',
},
},
},
Error: {
type: 'object',
properties: {
@@ -483,7 +566,7 @@ export const openApiSpec = {
properties: {
id: { type: 'string' },
model: { type: 'string' },
status: { enum: ['available', 'flying'] },
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
fuel: { type: 'number' },
maxFuel: { type: 'number' },
locationIcao: { type: 'string' },
@@ -502,6 +585,22 @@ export const openApiSpec = {
},
lat: { type: 'number' },
lon: { type: 'number' },
queue: {
type: 'array',
items: { type: 'string' },
description:
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
},
health: {
type: 'number',
description:
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
},
repairUntilSimTime: {
type: ['integer', 'null'],
description:
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
},
},
required: [
'id',
@@ -516,6 +615,9 @@ export const openApiSpec = {
'departureSimTime',
'lat',
'lon',
'queue',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
],
},
PackageState: {
@@ -529,10 +631,14 @@ export const openApiSpec = {
reward: { type: 'number' },
cargoType: { type: 'string' },
cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
},
@@ -545,6 +651,8 @@ export const openApiSpec = {
'reward',
'cargoType',
'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'deadlineSimTime',
'status',
'planeId',
+431 -9
View File
@@ -6,14 +6,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
clampTimeScale,
deliverPackages,
expirePackages,
finishRepairs,
MAX_TICK_ELAPSED_MS,
MAX_TIME_SCALE,
MIN_TIME_SCALE,
@@ -105,6 +108,7 @@ describe('expirePackages', () => {
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
]);
const n = await expirePackages(inst, clock);
@@ -116,6 +120,7 @@ describe('expirePackages', () => {
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
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 () => {
@@ -163,7 +168,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -182,8 +187,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form).
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
// (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -228,6 +236,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
); // 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]!;
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 },
]);
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);
// 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,
9,
);
});
it('leaves planes without route info alone', async () => {
const inst = await freshDb('deliver-noroute.db');
// Flying plane whose dest_icao is missing — defensive skip, no crash.
@@ -235,6 +263,399 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ status: 'flying', departureSimTime: T0 });
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
});
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // chained — still airborne, no ground stop
expect(plane.destIcao).toBe('EHAM'); // first queued leg is now the active one
expect(plane.departureSimTime).toBe(clock); // departed at the arrival instant…
expect(plane.locationIcao).toBe(TO); // …from the airport just landed at
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
});
it('auto-refuels a short tank and chains the next queued leg when cash allows (ISS-082)', async () => {
const { inst, from, to, km, type } = await setupFlight('chain-autorefuel.db');
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty; the LEMD→EHAM hop 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']) })
.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 clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
// requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
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); // the chained leg departs from the arrival airport
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(accountBefore.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'); // chained — the top-up funded it…
expect(plane.destIcao).toBe('EHAM');
expect(plane.departureSimTime).toBe(clock);
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
});
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.
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 });
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded safely…
expect(plane.destIcao).toBeNull();
expect(plane.departureSimTime).toBeNull();
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);
});
it('grounds as before when the queue is empty', async () => {
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // unchanged landing behavior…
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
});
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
const wTo = weatherFor(TO);
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
);
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['FGSL']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
});
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
});
});
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
async function setupFlight(name: string) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, to, km, type };
}
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
const { inst, km } = await setupFlight('wear-leg.db');
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
});
it('clamps at 0 across multiple legs', async () => {
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
// Start below one leg's wear so the first landing clamps…
await inst.db.update(schema.planes)
.set({ health: WEAR_PER_KM * km / 2 })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // clamped — never negative…
// …and a second leg keeps it pinned at 0.
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // still clamped after the second leg
});
it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
// Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
// guard can explain why it doesn't chain.
await inst.db.update(schema.planes)
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // broken by this landing's wear…
expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
});
});
describe('finishRepairs (v0.39.0, ISS-069)', () => {
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
const inst = await freshDb('repair-finish.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
});
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
const inst = await freshDb('repair-tick.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
await tickOnce(inst);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('repair-curve.db');
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
const gate = T0 + REPAIR_DURATION_MS;
await inst.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(50);
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
const inst2 = await freshDb('repair-curve-floor.db');
await inst2.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
// At the gate: exactly 100 and back in service.
expect(await finishRepairs(inst, gate)).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
});
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
// any advancing tick past T0 finalizes it. timeScale is the value under test;
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
async function setupLanding(name: string, timeScale: number) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, km, type };
}
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
const { inst } = await setupLanding('slow-last.db', 1000);
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
expect(sim.timeScale).toBe(1); // …slowed to the floor…
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
});
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
const { inst, from, type } = await setupLanding('slow-other.db', 1000);
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
await inst.db.insert(schema.planes).values({
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0,
});
vi.setSystemTime(T0 + 2_000);
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
});
it('already at 1× → stays 1 (no-op slow)', async () => {
const { inst } = await setupLanding('slow-one.db', 1);
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
});
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
expect(plane.destIcao).toBe('EDDV');
});
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
const inst = await freshDb('slow-paused.db');
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
vi.setSystemTime(T0 + 5_000);
const sim = await tickOnce(inst);
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
expect(sim.timeScale).toBe(1000);
expect(sim.lastSelectedTimeScale).toBe(1000);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
});
});
describe('spawnOpenPackages', () => {
@@ -262,9 +683,10 @@ describe('spawnOpenPackages', () => {
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
expect(p.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time.
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
// Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale);
}
// Already at target → nothing more to spawn.
+211 -31
View File
@@ -3,16 +3,20 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state';
import {
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
mulberry32,
pickDestWithinCap,
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -71,17 +75,39 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
const clockMs = Math.round(sim.clockMs + advanceMs);
let landedThisTick = 0;
if (advanceMs > 0) {
await expirePackages(inst, clockMs);
await deliverPackages(inst, clockMs);
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
landedThisTick = await deliverPackages(inst, clockMs);
// 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.
await finishRepairs(inst, clockMs);
await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned
// at its new location within the same tick.
await spawnCoveragePackages(inst, clockMs);
}
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
clockMs,
lastRealTickMs: nowMs,
};
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
if (landedThisTick > 0) {
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
}
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
await inst.db.update(schema.simState).set(sets);
await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing after tick');
@@ -104,13 +130,52 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
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
// back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as
// expirePackages: one guarded pass over the rows. Returns the count flipped.
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
let n = 0;
for (const p of rows) {
if (p.repairUntilSimTime === null) continue; // defensive — repairing always carries a gate
// ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a
// plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the
// start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive,
// as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane
// is still 'repairing' and grounded until the gate passes.
const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS));
if (p.repairUntilSimTime <= clockMs) {
await inst.db.update(schema.planes)
.set({ status: 'available', health: 100, repairUntilSimTime: null })
.where(eq(schema.planes.id, p.id));
n++;
} else {
const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100)));
if (newHealth !== p.health) {
await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id));
}
}
}
return n;
}
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
// ground the plane at its dest_icao with its remaining fuel, then for every
// package bound to it and destined for that airport — 'loaded' ones still inside
// their deadline are delivered (cash += reward + reputation gain per package),
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
// on the plane for a later leg. Idempotent — a finalized plane leaves the
// 'flying' state, so it can never be awarded twice. Returns planes landed.
// for every package bound to the plane and destined for its dest_icao — 'loaded'
// ones still inside their deadline are delivered (cash += reward + reputation
// gain per package), 'expired' ones just unbind (no reward). Packages destined
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
// auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
// broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
// airport; with a short tank, ISS-082 (v0.42.0) tops
// up to 150% of the requirement (capped at tank capacity) when cash allows and
// chains anyway — only an unaffordable top-up grounds the plane there with its
// remaining fuel and the queue kept intact for a later dispatch.
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
// leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes),
@@ -131,20 +196,23 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
await inst.db.update(schema.planes)
.set({
status: 'available',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
departureSimTime: null,
destIcao: null,
})
.where(eq(schema.planes.id, p.id));
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Deliver every bound package whose destination is the airport just reached.
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
// Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043).
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
@@ -152,15 +220,100 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// 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');
// 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({
cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
});
} else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot.
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
}
}
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0
// (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with
// the queue intact until repaired, exactly like flyPlane's broken guard.
const queue = parseIcaoQueue(p.queue);
let chainedTo: string | null = null;
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0 && newHealth > 0) {
const nextDest = queue[0];
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
// zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
if (nextDest === to.icao) {
queue.shift();
} else {
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
// v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
// intact, mirroring flyPlane's 409 without throwing in the engine.
if (nextRequiredL > type.maxFuel) {
// no chain — the grounded update below keeps the queue for a later dispatch
} else if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
} else {
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
}
}
}
}
await inst.db.update(schema.planes)
.set(
chainedTo
? {
status: 'flying',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: topUpFuel ?? landedFuel, // ISS-082: the post-top-up level when a top-up funded the chain
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
destIcao: chainedTo,
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
queue: JSON.stringify(queue),
}
: {
status: 'available',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: landedFuel,
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
departureSimTime: null,
destIcao: null,
queue: JSON.stringify(queue),
},
)
.where(eq(schema.planes.id, p.id));
n++;
}
return n;
@@ -188,9 +341,14 @@ export async function spawnOpenPackages(
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 }));
await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
);
}
return needed;
}
@@ -232,12 +390,27 @@ 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');
let n = 0;
for (const icao of missing) {
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
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 }),
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++;
}
@@ -265,13 +438,20 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
// Tick first so the returned state reflects current time, then apply the new
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
// the route handler (400 on non-number / non-boolean).
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
// the last grounded plane takes off. A {paused}-only post never touches it, so the
// last manual intent wins without an extra flag.
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
return withLock(async () => {
const inst = await getGameDb();
await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) {
const scale = clampTimeScale(input.timeScale);
sets.timeScale = scale;
sets.lastSelectedTimeScale = scale;
}
if (input.paused !== undefined) sets.paused = input.paused;
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
await persist(inst);
+70
View File
@@ -5,10 +5,12 @@ import {
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
maxRangeKm,
positionAt,
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -164,3 +166,71 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
expect(r).toBeGreaterThan(4439 * 0.95);
expect(r).toBeLessThan(4439 * 1.05);
});
it('increases with tank size and decreases with burn rate', () => {
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
});
it('zero fuel → zero range', () => {
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
});
it('rejects a non-positive empty weight', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
+19
View File
@@ -118,3 +118,22 @@ export function requiredFuelFor(
const rho = FUEL_DENSITY_KG_PER_L;
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
}
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
return 1 + headwindKt / 150;
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
// itself does not model range exhaustion (see fuelAtWeighted).
export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0');
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { StateSnapshot } from '@/lib/game/state';
// 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;
// every success returns the FULL post-action snapshot (v0.8.0 convention), pushed into the
// shared shell state immediately via applySnapshot so the UI updates without waiting for the
// next poll tick. A non-2xx {error} body becomes the pane's inline error AND is mirrored to
// the shell as a bottom-right toast via onError (ISS-008 — both surfaces by design). Buttons
// are disabled while a request is in flight, so at most one action runs per pane at a time.
export function useGameAction(
state: StateSnapshot | null,
applySnapshot: (snapshot: StateSnapshot) => void,
onError?: (message: string) => void,
): { runAction: (path: string, body?: unknown) => Promise<void>; busy: boolean; actionError: string | null } {
const [busy, setBusy] = useState(false);
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
// snapshot prop changes every tick) or the next successful action.
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
const runAction = useCallback(
async (path: string, body?: unknown) => {
if (busy) return;
setBusy(true);
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setActionError(null);
applySnapshot(data);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(message); // inline pane error stays…
onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008)
} finally {
setBusy(false);
}
},
[applySnapshot, busy, onError],
);
return { runAction, busy, actionError };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
// this suite pins the math: game time advances real-time × timeScale, frozen while paused.
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
clockMs: 1_750_000_000_000,
at: 1_000_000,
timeScale: 1,
paused: false,
...over,
});
describe('advanceSimClock', () => {
it('advances by exactly elapsed real-time × timeScale while running', () => {
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000); // scale 1: 1 s real = 1 s game
expect(advanceSimClock(anchor({ timeScale: 25 }), 1_002_500)).toBe(1_750_000_000_000 + 62_500);
// The e2e scenario: scale 1000, 2.5 s real → 2 500 game seconds.
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_002_500)).toBe(1_750_000_000_000 + 2_500_000);
});
it('returns the anchor value exactly at zero elapsed', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_000_000)).toBe(1_750_000_000_000);
});
it('is frozen while paused regardless of elapsed or scale', () => {
const a = anchor({ timeScale: 1000, paused: true });
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
});
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor({ paused: true }), 999_999)).toBe(1_750_000_000_000);
});
it('mirrors the server tick model (tickOnce): advance = elapsed × timeScale while actively polled', () => {
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
const scale = 50;
for (const gap of [1, 250, 999, 1_999]) {
expect(advanceSimClock(anchor({ timeScale: scale }), 1_000_000 + gap)).toBe(
1_750_000_000_000 + gap * scale,
);
}
});
});
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × timeScale
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
// server truth, bounding any drift by network latency only.
export interface SimClockAnchor {
// Server truth at anchor time — the snapshot's simState.clockMs.
clockMs: number;
// Date.now() when that snapshot was received.
at: number;
timeScale: number;
paused: boolean;
}
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to
// the anchor value instead of going backwards. Unit-testable in node.
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
if (anchor.paused) return anchor.clockMs;
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale;
}
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
const DISPLAY_TICK_MS = 100;
export interface SimClockControls {
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
// UI changes take effect on click/drag without waiting for a confirming snapshot.
timeScale: number;
paused: boolean;
}
// Returns the display clockMs to render, or null until the first snapshot arrives.
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null {
const anchorRef = useRef<SimClockAnchor | null>(null);
const [displayMs, setDisplayMs] = useState<number | null>(null);
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot
// object is fresh per poll (useGameState), so this runs once per poll cycle as well.
useEffect(() => {
if (!snapshot) return;
anchorRef.current = {
clockMs: snapshot.simState.clockMs,
at: Date.now(),
timeScale: controls.timeScale,
paused: controls.paused,
};
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
}, [snapshot, controls.timeScale, controls.paused]);
useEffect(() => {
const id = setInterval(() => {
const anchor = anchorRef.current;
if (anchor) setDisplayMs(advanceSimClock(anchor, Date.now()));
}, DISPLAY_TICK_MS);
return () => clearInterval(id);
}, []);
// First paint after a snapshot arrives happens before the re-sync effect runs — show the
// snapshot value directly so the clock never blanks for a frame.
return displayMs ?? (snapshot ? snapshot.simState.clockMs : null);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { MAX_TOASTS, pushToast, type Toast } from './useToasts';
// ISS-008 (v0.17.0): the toast queue's pure core — append + cap at MAX_TOASTS dropping the
// oldest. The hook itself is a thin useState wrapper around this (auto-dismiss timers are
// plain setTimeout, covered by e2e via the visible toasts).
const toast = (id: number, message: string): Toast => ({ id, kind: 'info', message });
describe('pushToast', () => {
it('appends to an empty queue and preserves order', () => {
expect(pushToast([], toast(1, 'a'))).toEqual([toast(1, 'a')]);
expect(pushToast([toast(1, 'a')], toast(2, 'b'))).toEqual([toast(1, 'a'), toast(2, 'b')]);
});
it('keeps the newest MAX_TOASTS entries, dropping the oldest beyond the cap', () => {
let queue: Toast[] = [];
for (let id = 1; id <= MAX_TOASTS + 2; id++) queue = pushToast(queue, toast(id, `m${id}`));
expect(queue).toEqual([toast(MAX_TOASTS - 1, 'm3'), toast(MAX_TOASTS, 'm4'), toast(MAX_TOASTS + 1, 'm5'), toast(MAX_TOASTS + 2, 'm6')]);
});
it('does not mutate the input queue', () => {
const before = [toast(1, 'a')];
pushToast(before, toast(2, 'b'));
expect(before).toEqual([toast(1, 'a')]);
});
});
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useCallback, useRef, useState } from 'react';
// ISS-008 (v0.17.0): transient toast notifications — a bottom-right stack for action errors
// and game events derived from snapshot diffs. Each toast auto-dismisses after TOAST_TTL_MS;
// the queue is capped at MAX_TOASTS (oldest dropped) so an event burst can't fill the corner.
export type ToastKind = 'error' | 'info';
export interface Toast {
id: number;
kind: ToastKind;
message: string;
}
export const TOAST_TTL_MS = 5_000;
export const MAX_TOASTS = 4;
// Pure core (unit-tested): append a toast, dropping the oldest beyond the cap.
export function pushToast(toasts: Toast[], toast: Toast): Toast[] {
return [...toasts, toast].slice(-MAX_TOASTS);
}
export interface UseToastsResult {
toasts: Toast[];
push: (kind: ToastKind, message: string) => void;
dismiss: (id: number) => void;
}
export function useToasts(): UseToastsResult {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextIdRef = useRef(1);
// Functional update — a late timer for an already-dismissed/dropped id is a no-op.
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(kind: ToastKind, message: string) => {
const toast: Toast = { id: nextIdRef.current++, kind, message };
setToasts((current) => pushToast(current, toast));
setTimeout(() => dismiss(toast.id), TOAST_TTL_MS);
},
[dismiss],
);
return { toasts, push, dismiss };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.16.0",
"version": "0.48.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Build data/airports_world.csv from the full OurAirports world dump (ISS-079, v0.44.0).
//
// 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").
// 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)
// Aborts without writing anything if the download fails after retries — no fabricated data.
import { promises as fs } from 'node:fs';
import path from 'node:path';
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
} else field += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c !== '\r') field += c;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
const [header, ...rest] = rows;
return rest
.filter((r) => r.length === header.length && !(r.length === 1 && r[0] === ''))
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
}
function haversineKm(a, b) {
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.latitude_deg - a.latitude_deg);
const dLon = toRad(b.longitude_deg - a.longitude_deg);
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.latitude_deg)) * Math.cos(toRad(b.latitude_deg)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(x)));
}
// "Biggest" proxy — the CSV carries no passenger/traffic data, so rank by service signals:
// scheduled_service first, then an IATA code (commercially served), then a Wikipedia page,
// then file order. ponytail: good-enough proxy for hub-vs-satellite; swap in pax data if it ever exists.
function biggestOf(members) {
const rank = (r) => [r.scheduled_service === 'yes' ? 1 : 0, r.iata_code ? 1 : 0, r.wikipedia_link ? 1 : 0];
let best = members[0];
for (const m of members.slice(1)) {
const rb = rank(m);
const rk = rank(best);
if (rb[0] > rk[0] || (rb[0] === rk[0] && rb[1] > rk[1]) || (rb[0] === rk[0] && rb[1] === rk[1] && rb[2] > rk[2])) best = m;
}
return best; // ties keep the lowest file order (members are in file order)
}
async function download(url, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (!(text.startsWith('"id"') || text.startsWith('id,')) || text.split('\n').length < 10_000) throw new Error('response does not look like the OurAirports CSV');
return text;
} catch (err) {
console.error(`download attempt ${i}/${attempts} failed: ${err.message}`);
if (i === attempts) throw err;
await new Promise((r) => setTimeout(r, 2000 * i));
}
}
throw new Error('unreachable');
}
const text = await download(SOURCE_URL);
const [headerLine] = text.split('\n');
const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"|"$/g, ''));
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
const allRows = parseCsv(text);
const large = allRows.filter((r) => r.type === 'large_airport');
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
// radius, otherwise it starts a new one. Deterministic — same input, same output.
const clusters = []; // { anchorIdx (into `large`), members }
for (let i = 0; i < large.length; i++) {
let joined = false;
for (const c of clusters) {
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
}
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
}
const kept = [];
const multiClusters = [];
for (const c of clusters) {
const winner = biggestOf(c.members.map((i) => large[i]));
kept.push(winner);
if (c.members.length > 1) {
multiClusters.push({
city: winner.municipality || winner.name,
keptIcao: winner.icao_code || winner.ident,
dropped: c.members.map((i) => large[i]).filter((r) => r !== winner).map((r) => `${r.icao_code || r.ident} (${r.name})`),
});
}
}
// --- Guards (fail loud before writing anything) ---
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
const keys = new Set(kept.map(key));
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
}
if (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); process.exit(1); }
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
console.log(`wrote ${OUT_FILE}: ${kept.length} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
console.log('sample merged cities (kept ← dropped):');
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);