Compare commits

...
114 Commits
Author SHA1 Message Date
kamilkrzywda 2a4b341a9c Merge pull request 'v0.53.0 — antimeridian routes no longer chord across the map (#120)' (#123) from feat/antimeridian-route into master
CI / build + unit + e2e (push) Successful in 12m43s
2026-09-22 17:00:15 +02:00
kamilkrzywda cd2c766895 Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120)
CI / build + unit + e2e (pull_request) Successful in 10m58s
2026-09-22 14:48:53 +00:00
kamilkrzywda 23896b2cc4 Merge pull request 'v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)' (#121) from feat/fixed-world-clock into master
CI / build + unit + e2e (push) Successful in 12m28s
v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)
2026-09-22 16:13:41 +02:00
kamilkrzywda 189a2c04eb Fix e2e specs for fixed world clock: idle-pause expiry window, scale-1 motion settle, self-heal (ISS-117)
CI / build + unit + e2e (pull_request) Successful in 12m44s
2026-09-22 14:00:53 +00:00
kamilkrzywda a0560ec767 Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

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

E2E +1: relocation.spec.ts dispatches the starter on a leg via API,
slows the sim to 1x, opens the popup for a different airport, selects
the flying plane and clicks Relocate here — asserts the queue contains
the clicked ICAO while the plane still flies its original destination.
2026-09-22 07:32:31 +00:00
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
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
93 changed files with 9153 additions and 2262 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 # Flight Simulator — Workflow Rules
## Change process (mandatory) ## 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`. 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). 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`. 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. 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) ## 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**. 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. **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. 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`).
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.
## API ## API
1. **Live base URL** — `https://flight.dev.kkhost.pl` (the docker-compose build behind Traefik). 1. **Live base URL** — `https://flight.dev.kkhost.pl` (the docker-compose build behind Traefik).
+272
View File
@@ -1,5 +1,277 @@
# Changelog # Changelog
## v0.53.0 — 2026-09-22 14:47
- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
## v0.52.0 — 2026-09-22 10:45
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve).
## v0.51.0 — 2026-09-22 08:21
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
## v0.50.0 — 2026-09-22 08:00
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
## v0.49.0 — 2026-09-22 07:25
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
## v0.47.0 — 2026-09-21 23:01
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
- **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 ## 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): - **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) - `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) ## Layout (single screen)
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation - 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) - 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: EU airports as markers, animated plane positions along routes, active contract routes highlighted; click airport → info (runways, local time, weather); click plane → status - 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 ## Core loop
Accept job → dispatch a plane → watch it fly the route on the map → earn money + reputation → buy better planes / take bigger jobs. 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 - Shown on map / airport info; no gameplay effects yet
## MVP slice ## 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 ## Later
- Weather effects: delays, fuel burn changes, closed airports - Weather effects: delays, fuel burn changes, closed airports
- More aircraft types, upgrades (fuel efficiency, cargo capacity) — money sinks - More aircraft types, upgrades (fuel efficiency, cargo capacity) — money sinks
- Multi-region packs (non-EU airports)
- Multiplayer (shared world, contract races) - Multiplayer (shared world, contract races)
+1 -1
View File
@@ -94,7 +94,7 @@ describe('POST /api/packages/:id/abandon', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
packages: Array<{ id: string; status: string; planeId: string | null }>; packages: Array<{ id: string; status: string; planeId: string | null }>;
}; };
expect(Object.keys(body).sort()).toEqual([ expect(Object.keys(body).sort()).toEqual([
+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));
}
+1 -1
View File
@@ -92,7 +92,7 @@ describe('POST /api/packages/:id/load', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<{ id: string; status: string; planeId: string | null }>; packages: Array<{ id: string; status: string; planeId: string | null }>;
}; };
+5 -4
View File
@@ -1,10 +1,11 @@
import { loadPackage, performAction } from '@/lib/game/actions'; import { loadPackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN // 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 // package onto a GROUNDED plane at the package's origin with enough payload room
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409 // for its weight (v0.27.0). 400 missing/non-string planeId or unknown fields; 404
// wrong package status, plane flying, plane not at the origin, no free slots. On // unknown package/plane id; 409 wrong package status, plane flying, plane not at
// success returns the full state snapshot (package now "loaded" on that plane). // 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 }> }) { export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params; const { id } = await ctx.params;
+7 -7
View File
@@ -63,17 +63,17 @@ describe('POST /api/planes/:planeId/fly', () => {
.where(eq(schema.planes.id, 'plane-starter')); .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(); const { db } = await getGameDb();
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L. // 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({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter')); 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(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i); expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Refill for later tests. // Put the starter back at the hub for later tests.
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter')); 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 () => { it('flies the plane and returns the full snapshot with departure + destination set', async () => {
@@ -84,7 +84,7 @@ describe('POST /api/planes/:planeId/fly', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>; planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
}; };
expect(Object.keys(body).sort()).toEqual([ expect(Object.keys(body).sort()).toEqual([
@@ -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));
}
-99
View File
@@ -1,99 +0,0 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { POST } from './route';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) }));
const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text }));
describe('POST /api/sim', () => {
it('returns the current simState for an empty body (tick + no-op)', async () => {
const res = await post({});
expect(res.status).toBe(200);
const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean };
expect(typeof body.clockMs).toBe('number');
expect(body.timeScale).toBe(1); // seeded default
expect(body.paused).toBe(false);
});
it('sets and clamps timeScale into [1, 1000]', async () => {
const hi = await post({ timeScale: 2000 });
expect(hi.status).toBe(200);
expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000);
const lo = await post({ timeScale: 0.1 });
expect(lo.status).toBe(200);
expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1);
// The top of the ladder passes through unclamped (v0.14.0).
const max = await post({ timeScale: 1000 });
expect(max.status).toBe(200);
expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000);
// In-range values pass through unclamped — ladder and off-ladder alike.
const ladder = await post({ timeScale: 500 });
expect(ladder.status).toBe(200);
expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500);
const mid = await post({ timeScale: 37 });
expect(mid.status).toBe(200);
expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37);
// Restore the default for later tests.
await post({ timeScale: 1 });
});
it('toggles paused and freezes the clock across ticks', async () => {
const pause = await post({ paused: true });
expect(pause.status).toBe(200);
expect(((await pause.json()) as { paused: boolean }).paused).toBe(true);
// Two reads while paused → identical clock (tick-on-read advances nothing).
const a = await post({});
const b = await post({});
expect(((await a.json()) as { clockMs: number }).clockMs).toBe(
((await b.json()) as { clockMs: number }).clockMs,
);
const resume = await post({ paused: false });
expect(resume.status).toBe(200);
expect(((await resume.json()) as { paused: boolean }).paused).toBe(false);
});
it('applies both fields in one call', async () => {
const res = await post({ timeScale: 64, paused: true });
expect(res.status).toBe(200);
const body = (await res.json()) as { timeScale: number; paused: boolean };
expect(body.timeScale).toBe(64);
expect(body.paused).toBe(true);
await post({ timeScale: 1, paused: false }); // restore
});
it('rejects invalid input with 400', async () => {
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
for (const text of ['nope{', '[1,2]', '"str"', 'null']) {
const res = await postRaw(text);
expect(res.status).toBe(400);
}
});
});
-42
View File
@@ -1,42 +0,0 @@
import { applyTimeControls } from '@/lib/sim/engine';
// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }.
// Ticks the clock first (so the response reflects current game time), then applies
// and persists the new controls. Returns the updated simState.
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'timeScale' && key !== 'paused') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) {
return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 });
}
if (input.paused !== undefined && typeof input.paused !== 'boolean') {
return Response.json({ error: 'paused must be a boolean' }, { status: 400 });
}
try {
const sim = await applyTimeControls({
timeScale: input.timeScale as number | undefined,
paused: input.paused as boolean | undefined,
});
return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused });
} catch (err) {
console.error('failed to apply sim controls', err);
return Response.json({ error: 'failed to apply sim controls' }, { status: 500 });
}
}
+16 -5
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route'; import { GET } from './route';
@@ -41,10 +43,8 @@ describe('GET /api/state', () => {
expect(typeof account.cash).toBe('number'); expect(typeof account.cash).toBe('number');
expect(typeof account.reputation).toBe('number'); expect(typeof account.reputation).toBe('number');
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean }; const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
expect(typeof sim.clockMs).toBe('number'); expect(typeof sim.clockMs).toBe('number');
expect(typeof sim.timeScale).toBe('number');
expect(sim.paused).toBe(false);
const planes = body.planes as Array<Record<string, unknown>>; const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1); expect(planes.length).toBeGreaterThanOrEqual(1);
@@ -58,9 +58,20 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>; const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0); expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key); 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 () => { it('is stable across repeated calls (same seeded DB)', async () => {
@@ -86,7 +97,7 @@ describe('GET /api/state', () => {
expect(entry.maxFuel).toBe(type.maxFuel); expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm); expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh); 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. // 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. // same gate, same conventions.
// //
// POST /api/test/create-package // 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); // - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generatePackage. // omitted endpoints are picked at random by generatePackage.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short // - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// hook deadline of expire-package) — pass an explicit value to override. // 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. // Returns 201 + the created package row.
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -38,7 +43,7 @@ export async function POST(req: Request) {
} }
const input = body as Record<string, unknown>; const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) { 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 }); 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))) { if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 }); 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 { try {
const inst = await getGameDb(); const inst = await getGameDb();
@@ -71,6 +82,10 @@ export async function POST(req: Request) {
} }
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// 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({ const created = generatePackage({
id: randomUUID(), id: randomUUID(),
airports, airports,
@@ -79,6 +94,10 @@ export async function POST(req: Request) {
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined, deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
originIcao: input.originIcao as string | undefined, originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao 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 inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+2 -1
View File
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
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 inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
return Response.json(created, { status: 201 }); 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 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { setTestTimeScale } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now
// that there are no player-facing controls; without it every spec would run at the fixed world
// rate and a cross-Europe leg would take hours of real time. Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/set-health — same gate, same conventions.
//
// POST /api/test/set-timescale
// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise).
// Ticks first so the write lands on current game state, under the sim lock.
// Returns { clockMs, timeScale } — the new effective scale for test assertions.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'timeScale') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const timeScale = input.timeScale;
if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) {
return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 });
}
try {
const row = await setTestTimeScale(timeScale);
return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+249 -8
View File
@@ -48,6 +48,16 @@ body {
color: #94a3b8; 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 switcher — kept subtle, like the version tag. */
.style-switch { .style-switch {
font-size: 12px; font-size: 12px;
@@ -68,10 +78,46 @@ body {
color: #cbd5e1; 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 { .app-shell {
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr) 360px; grid-template-columns: 360px minmax(0, 1fr) 360px;
height: 100%; height: 100%;
} }
@@ -178,6 +224,26 @@ body {
color: #bfdbfe; 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 { .fuel-bar {
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
@@ -192,6 +258,26 @@ body {
background: #34d399; 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 { .plane-sub {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -327,6 +413,12 @@ body {
padding: 4px 0; 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 { .market-preview {
font-size: 12px; font-size: 12px;
color: #f8fafc; color: #f8fafc;
@@ -334,16 +426,165 @@ body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* v0.12.0 — on-board blocks (per plane) in the right pane */ /* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
.onboard-block { 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); background: rgba(30, 41, 59, 0.6);
border-radius: 6px; border-radius: 6px;
padding: 8px; padding: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.onboard-block .contract-row { .plane-type-name {
background: transparent; margin: 0 0 6px;
padding: 6px 0 0; font-size: 13px;
border-top: 1px solid rgba(148, 163, 184, 0.2); font-weight: 600;
color: #f1f5f9;
} }
+33 -6
View File
@@ -1,11 +1,14 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane'; import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane'; import RightPane from '@/components/RightPane';
import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes'; import type { HoverTarget } from '@/components/hoverTypes';
import { useGameState } from '@/lib/useGameState'; import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false }); const Map = dynamic(() => import('@/components/Map'), { ssr: false });
@@ -13,18 +16,42 @@ export default function Home() {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately. // lets a pane push an action endpoint's full response snapshot in immediately.
const { state, loading, error, refresh, applySnapshot } = useGameState(1000); const { state, loading, error, applySnapshot } = useGameState(1000);
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in // ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
// either pane highlights the matching plane/route/airports on the map. // either pane highlights the matching plane/route/airports on the map.
const [hovered, setHovered] = useState<HoverTarget | null>(null); 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 ( return (
<div className="app-shell"> <div className="app-shell">
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} onHover={setHovered} /> {/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */} fly/abandon actions push their response snapshot into the shared state */}
<Map state={state} hovered={hovered} /> <LeftPane state={state} loading={loading} error={error} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} /> {/* 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> </div>
); );
} }
+338 -183
View File
@@ -1,171 +1,117 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; import { abandonPenalty } from '@/lib/game/penalty';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
import { cargoWeightFor } from './RightPane';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps { interface LeftPaneProps {
state: StateSnapshot | null; state: StateSnapshot | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
onRefresh: () => void; // 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. // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void; 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
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
// roughly doubles the speed.
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
const SCALE_COMMIT_DEBOUNCE_MS = 250;
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0). // Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
function planeLocation(p: PlaneState): string { function planeLocation(p: PlaneState): string {
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`; if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
return p.locationName; 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 { function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = state.packages.filter( const used = cargoWeightFor(state, p.id);
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'), const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
).length; return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
return `${used}/${capacity} pkgs`;
} }
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back // ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot. const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
return pendingScale !== null && serverTimeScale === pendingScale;
}
export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) { // ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
const [busy, setBusy] = useState(false); // "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);
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
// controls without waiting for the next poll tick. On failure keep the stale UI — the // Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
// running poll retries on its own and surfaces the error state. A commit that lands while // on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
// another POST is in flight is held in a single slot (last intent wins) and flushed by the // from RightPane in ISS-023 (v0.21.0) — the relocate-dest select was its only consumer there.
// in-flight call, so every user intent reaches the server (the [1,1000] clamp stays as backstop). const [airportOptions, setAirportOptions] = useState<Array<{ name: string; icao: string }> | null>(null);
// The gate is a REF, not React state: closures never see a stale "busy" value, which used to const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | null>(null);
// let a flush re-queue instead of send and silently drop a commit. useEffect(() => {
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null); if (!airportsPromiseRef.current) {
const busyRef = useRef(false); airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' })
.then((res) => {
type SimControlsOutcome = 'sent' | 'queued' | 'failed'; if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
return res.json() as Promise<GeoJsonFeatureCollection>;
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh). })
// 'queued': an in-flight call holds it — its flush will send it. .then((fc) =>
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then. fc.features
const applySimControls = useCallback( .map((f) => ({ name: f.properties.name, icao: f.properties.icao }))
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => { .sort((a, b) => a.name.localeCompare(b.name, 'en')),
if (busyRef.current) { );
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it }
return 'queued'; void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
}
busyRef.current = true;
setBusy(true);
let sentOwn = false;
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
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);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
setBusy(false);
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh],
);
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
// user releases the slider (browsers fire a native 'change' on release). The server's
// [1,1000] clamp stays as the backstop.
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
const pendingRef = useRef<number | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setPending = useCallback((s: number | null) => {
pendingRef.current = s;
setPendingScaleState(s);
}, []); }, []);
const commitNow = useCallback(() => { // Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
if (commitTimerRef.current !== null) { const clockMs = state?.simState.clockMs ?? 0;
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
const s = pendingRef.current;
if (s === null) return;
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
// call flushes it); only a genuine failure drops it so the display falls back to truth.
void applySimControls({ timeScale: s }).then((outcome) => {
if (outcome === 'failed') setPending(null);
});
}, [applySimControls, setPending]);
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in // ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
// flight, the optimistic value has been superseded by server truth — clear it (display is // endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD // market row; with state null both fall back to null and the section isn't rendered.
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a // ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
// quick drag back to the previous scale must not be swallowed before its own commit fires). // 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));
// 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(() => { useEffect(() => {
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null); if ('Notification' in window) setNotifPermission(Notification.permission);
}, [state, pendingScale, busy, setPending]); else setNotifPermission('unsupported');
}, []);
const onSliderInput = useCallback( // null (pre-effect) and 'default' both read as the opt-in prompt.
(e: ChangeEvent<HTMLInputElement>) => { const notifLabel: string =
setPending(sliderToScale(Number(e.target.value))); notifPermission === 'granted'
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); ? 'Notifications enabled'
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS); : notifPermission === 'denied'
}, ? 'Notifications blocked'
[commitNow, setPending], : notifPermission === 'unsupported'
); ? 'Notifications unavailable'
: 'Enable notifications';
const sliderRef = useRef<HTMLInputElement>(null); // ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
useEffect(() => { // server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
const el = sliderRef.current; // paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
if (!el) return; // the server clock).
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release const simClockMs = useSimClock(state);
return () => el.removeEventListener('change', commitNow);
}, [commitNow]);
// Drop any pending debounce timer on unmount.
useEffect(
() => () => {
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
},
[],
);
const displayedScale = pendingScale ?? timeScale;
return ( return (
<aside data-testid="left-pane" className="pane left-pane"> <aside data-testid="left-pane" className="pane left-pane">
@@ -191,68 +137,277 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
<p className="pane-muted">No planes.</p> <p className="pane-muted">No planes.</p>
) : ( ) : (
<ul data-testid="fleet-list" className="fleet-list"> <ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => ( {state.planes.map((p) => {
// ISS-002: hovering a fleet row highlights the plane on the map. // ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
<li // On board section's cargo rows and queue legs, so all plane info lives in one place.
key={p.id} const cargo = state.packages.filter(
data-testid={`plane-${p.id}`} (pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
className="plane-row" );
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })} return (
onMouseLeave={() => onHover(null)} // 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
<div className="plane-main"> // the whole block without flicker between sub-elements; cargo rows override with their
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span> // own package hover (below).
<span className={`plane-status status-${p.status}`}>{p.status}</span> <li
</div> key={p.id}
<div className="fuel-bar" aria-hidden="true"> data-testid={`plane-${p.id}`}
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} /> className="plane-row"
</div> onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
<div className="plane-sub"> onMouseLeave={() => onHover(null)}
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span> >
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span> <div className="plane-main">
<span className="plane-loc">{planeLocation(p)}</span> <span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
</div> {p.status === 'flying' ? (
</li> <>
))} <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> </ul>
)} )}
</section> </section>
<section className="pane-section"> {/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
<h3 className="pane-heading">Time</h3> everything else about our planes, between Fleet and Time. */}
<p data-testid="sim-clock" className="sim-clock"> {/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
{formatSimClock(state.simState.clockMs)} dashed trace from the selected plane's airport to the destination + a ring on the target.
{paused ? ' (paused)' : ''} With no planes or no loaded airport options there is nothing to preview → null. */}
</p> <section
<div className="time-controls"> 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 <button
type="button" type="button"
data-testid="pause-toggle" data-testid="relocate-go"
disabled={busy} className="pane-btn"
onClick={() => void applySimControls({ paused: !paused })} 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 });
}
}}
> >
{paused ? 'Resume' : 'Pause'} Relocate
</button> </button>
<input
ref={sliderRef}
type="range"
data-testid="time-scale-slider"
aria-label="Time scale"
min={0}
max={1}
step={SCALE_SLIDER_STEP}
value={scaleToSlider(displayedScale)}
onChange={onSliderInput}
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div> </div>
</section> </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>
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
only the clock display remains here. */}
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button
type="button"
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 && ( {error && (
<p data-testid="left-error" className="pane-error"> <p data-testid="left-error" className="pane-error">
{error} {error}
</p> </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} ) : null}
</aside> </aside>
+204 -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 { GeoJsonFeatureCollection } from '../lib/airports';
import type { StateSnapshot } from '../lib/game/state'; import type { StateSnapshot } from '../lib/game/state';
import type { HoverTarget } from './hoverTypes'; 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 { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime'; import { approxLocalTime } from '../lib/localTime';
@@ -28,11 +28,26 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
const PLANES_FLYING_LAYER_ID = 'planes-flying'; const PLANES_FLYING_LAYER_ID = 'planes-flying';
const ROUTES_SOURCE_ID = 'routes'; const ROUTES_SOURCE_ID = 'routes';
const ROUTES_LEGS_LAYER_ID = 'routes-legs'; 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-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they // 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. // render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight'; const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight'; const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight';
const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-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 { function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -43,13 +58,17 @@ interface MapProps {
// route lines. Null until the first successful poll. // route lines. Null until the first successful poll.
state?: StateSnapshot | null; state?: StateSnapshot | null;
// ISS-002 (v0.15.0): the side panels' hover target — highlights the matching plane // 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; 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 // Named FlightMap (not Map): a component called `Map` would shadow the global
// Map constructor inside its own body, breaking `new Map(...)` for the airport index. // Map constructor inside its own body, breaking `new Map(...)` for the airport index.
export default function FlightMap({ state, hovered }: MapProps) { export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event // /api/airports is fetched at most once per page life: every styledata event
@@ -58,6 +77,12 @@ export default function FlightMap({ state, hovered }: MapProps) {
// Latest snapshot + airport coordinate index, so the mount effect's styledata wiring // 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. // and the state-change effect can both push data without prop-drilling into closures.
const stateRef = useRef<StateSnapshot | null>(null); 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 airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null); const [airportCount, setAirportCount] = useState<number | null>(null);
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID); const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
@@ -76,11 +101,19 @@ export default function FlightMap({ state, hovered }: MapProps) {
if (routesSource && airportsIndexRef.current) { if (routesSource && airportsIndexRef.current) {
void routesSource.setData(routesGeoJson(st, 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(() => { useEffect(() => {
stateRef.current = state ?? null; stateRef.current = state ?? null;
applyDynamicRef.current(); 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]); }, [state]);
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above. // ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
@@ -92,22 +125,66 @@ export default function FlightMap({ state, hovered }: MapProps) {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const h = hoveredRef.current; const h = hoveredRef.current;
// The empty-string sentinel matches nothing: no feature has id/icao ''. // The empty-string sentinel matches nothing: no feature has id/icao ''. A package hover may
const planeId = h && h.kind === 'plane' ? h.id : ''; // 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)) { if (map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(PLANES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'id'], planeId]); map.setFilter(PLANES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'id'], planeId]);
} }
if (map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) { if (map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(ROUTES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'planeId'], planeId]); map.setFilter(ROUTES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'planeId'], planeId]);
} }
const originIcao = h && h.kind === 'package' ? h.originIcao : ''; // ISS-022/025/027: the hover route trace — one great-circle line for any target carrying two
const destIcao = h && h.kind === 'package' ? h.destIcao : ''; // 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)) { if (map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) {
map.setFilter( map.setFilter(
AIRPORTS_HIGHLIGHT_LAYER_ID, AIRPORTS_HIGHLIGHT_LAYER_ID,
['any', ['==', ['get', 'icao'], originIcao], ['==', ['get', 'icao'], destIcao]], ['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(() => { useEffect(() => {
@@ -156,16 +233,72 @@ export default function FlightMap({ state, hovered }: MapProps) {
// deterministic generated weather — no gameplay effects per PLAN.md. // deterministic generated weather — no gameplay effects per PLAN.md.
const w = weatherFor(p.icao); const w = weatherFor(p.icao);
const localTime = approxLocalTime(p.lon, Date.now()); const localTime = approxLocalTime(p.lon, Date.now());
new maplibregl.Popup({ offset: 10 }) // ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
const st = stateRef.current;
const planes = st ? st.planes : [];
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
const relocateHtml =
planes.length > 0
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
`<option value="">Select a plane\u2026</option>` +
planes.map(
(pl) =>
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
).join('') +
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
const popup = new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number]) .setLngLat(f.geometry.coordinates as [number, number])
.setHTML( .setHTML(
`<div data-testid="airport-popup">` + `<div data-testid="airport-popup">` +
`<strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}<br/>` + `<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-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>` + `<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>`, `</div>`,
) )
.addTo(map); .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 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 {
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
// planes fly now, flying/repairing ones get the leg queued for after landing.
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destIcao: p.icao }),
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 = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
} catch (err) {
statusEl.textContent = err instanceof Error ? err.message : String(err);
button.disabled = false; // allow retry / picking another plane
}
});
}
}; };
const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => { const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => {
@@ -224,9 +357,9 @@ export default function FlightMap({ state, hovered }: MapProps) {
type: 'circle', type: 'circle',
source: AIRPORTS_SOURCE_ID, source: AIRPORTS_SOURCE_ID,
paint: { 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-color': '#e11d48',
'circle-stroke-width': 1, 'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff', 'circle-stroke-color': '#ffffff',
}, },
}); });
@@ -250,6 +383,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. Amber/gold + fine dots reads as
// "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson',
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': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
},
});
}
if (!map.getSource(PLANES_SOURCE_ID)) { if (!map.getSource(PLANES_SOURCE_ID)) {
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) }); map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
} }
@@ -317,13 +471,48 @@ export default function FlightMap({ state, hovered }: MapProps) {
source: AIRPORTS_SOURCE_ID, source: AIRPORTS_SOURCE_ID,
filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']], filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']],
paint: { 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-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring
'circle-stroke-width': 2.5, 'circle-stroke-width': 2.5,
'circle-stroke-color': '#facc15', '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 // Layer-scoped listeners survive setStyle (they filter by layer id at event
// time), so off+on with the same reference re-wires without duplicating. // time), so off+on with the same reference re-wires without duplicating.
map.off('click', AIRPORTS_LAYER_ID, onAirportClick); map.off('click', AIRPORTS_LAYER_ID, onAirportClick);
@@ -365,7 +554,9 @@ export default function FlightMap({ state, hovered }: MapProps) {
<div className="map-wrap"> <div className="map-wrap">
<header data-testid="app-header" className="app-header"> <header data-testid="app-header" className="app-header">
<h1>Flight Ops</h1> <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 <select
data-testid="style-switch" data-testid="style-switch"
className="style-switch" className="style-switch"
@@ -380,7 +571,7 @@ export default function FlightMap({ state, hovered }: MapProps) {
))} ))}
</select> </select>
<span data-testid="airport-count"> <span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`} {airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span> </span>
</header> </header>
<div ref={containerRef} data-testid="map" className="map-container" /> <div ref={containerRef} data-testid="map" className="map-container" />
+136 -193
View File
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty'; import { guaranteedRangeKm } from '@/lib/sim/movement';
import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
interface RightPaneProps { 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 // ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or
// the package's endpoint airports. // the package's endpoint airports.
onHover: (target: HoverTarget | null) => void; 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; const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which // Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
// keeps its slot until it is abandoned or the plane lands at its destination. // cargo, which keeps its space until it is abandoned or the plane lands at its
export function slotsUsedFor(state: StateSnapshot, planeId: string): number { // destination.
return state.packages.filter( export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'), return state.packages
).length; .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 // Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
// free slot (capacity from the snapshot catalog). The Load button is enabled iff // payload room for it (maxPayloadKg from the snapshot catalog). The Load button is
// this list is non-empty; exactly one entry loads directly, several show a picker. // enabled iff this list is non-empty; exactly one entry loads directly, several show a picker.
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] { export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
return state.planes.filter((p) => { return state.planes.filter((p) => {
if (p.status !== 'available') return false; if (p.status !== 'available') return false;
if (p.locationIcao !== pkg.originIcao) return false; if (p.locationIcao !== pkg.originIcao) return false;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0; const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
return slotsUsedFor(state, p.id) < capacity; // 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 // Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when // is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its // several are, and a disabled button with the reason otherwise. Each row owns its
// selection in local state and defaults to the "Select a plane…" placeholder — // selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
// never preselecting — so rows stay visibly independent (v0.10.1 pattern). // 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({ function LoadControls({
pkg, pkg,
eligible, eligible,
@@ -57,8 +64,9 @@ function LoadControls({
onLoad: (planeId: string) => void; onLoad: (planeId: string) => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
// The user's pick while it stays eligible, else nothing (placeholder). // ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
const effective = eligible.find((p) => p.id === selectedId) ?? null; // 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) { if (eligible.length === 1) {
return ( return (
@@ -104,48 +112,23 @@ function LoadControls({
); );
} }
export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) { export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) {
const [busy, setBusy] = useState(false); // ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on
// Inline error from a failed action — auto-clears on the next successful poll (the shell's // failure, full post-action snapshot pushed into the shared state on success.
// snapshot prop changes every tick) or the next successful action. const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
// Market picker selections — same "effective selection" pattern as LoadControls: the // 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). // user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState<string | null>(null); const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = 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 // v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
// 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. // ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
const runAction = useCallback( // about our planes, not in the market. Its state + /api/airports fetch went with it.
async (path: string, body?: unknown) => { // ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
if (busy) return; // left pane's "Refuel" section next to Relocate.
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],
);
if (loading && !state) { if (loading && !state) {
return ( return (
@@ -160,33 +143,47 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
const clockMs = state.simState.clockMs; const clockMs = state.simState.clockMs;
const open = state.packages.filter((p) => p.status === 'open'); 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 catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null; const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
const grounded = state.planes.filter((p) => p.status === 'available'); 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 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). // 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 sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null; 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 ( return (
<aside data-testid="right-pane" className="pane right-pane"> <aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2> <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"> <section className="pane-section">
<h3 className="pane-heading">Packages</h3> <h3 className="pane-heading">Packages</h3>
{open.length === 0 ? ( {open.length === 0 ? (
@@ -206,6 +203,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
> >
<div className="contract-main"> <div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span> <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 ? ( {eligible.length > 0 ? (
<LoadControls <LoadControls
pkg={pkg} pkg={pkg}
@@ -218,10 +221,23 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
Load Load
</button> </button>
)} )}
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
<button
type="button"
data-testid={`ignore-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
>
Ignore
</button>
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<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}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span> </span>
@@ -232,94 +248,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
</ul> </ul>
)} )}
</section> </section>
)}
<section className="pane-section"> {/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
<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>
{activeTab === 'planes' && (
<section className="pane-section"> <section className="pane-section">
<h3 className="pane-heading">Market</h3> <h3 className="pane-heading">Market</h3>
@@ -337,8 +270,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
))} ))}
</select> </select>
<span data-testid="market-buy-price" className="market-preview"> <span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''} {buyPrice !== null ? formatCash(buyPrice) : ''}
</span> </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 <button
type="button" type="button"
data-testid="market-buy" data-testid="market-buy"
@@ -381,43 +318,49 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
</button> </button>
</div> </div>
<div className="market-row"> {/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{refuelable.length === 0 ? ( {/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full. {/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
</span> All values come from the snapshot's catalog; max range is the GUARANTEED full-tank range —
) : ( worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */}
<> {[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<select <div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
data-testid="market-refuel-plane" <h4 className="plane-type-name">{e.label}</h4>
aria-label="Plane to refuel" <div className="stat-row">
value={refuelPlane?.id ?? ''} <span>Buy</span>
onChange={(e) => setRefuelPlaneId(e.target.value || null)} <strong>{formatCash(e.price)}</strong>
> </div>
{refuelable.map((p) => ( <div className="stat-row">
<option key={p.id} value={p.id}> <span>Sell</span>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L <strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
</option> </div>
))} <div className="stat-row">
</select> <span>Payload</span>
<span data-testid="market-refuel-cost" className="market-preview"> <strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
{refuelCost !== null ? formatCash(refuelCost) : ''} </div>
</span> <div className="stat-row">
</> <span>Cruise speed</span>
)} <strong>{e.speedKmh} km/h</strong>
<button </div>
type="button" <div className="stat-row">
data-testid="market-refuel" <span>Fuel burn</span>
className="pane-btn" <strong>{e.burnPerKm} L/km</strong>
disabled={busy || refuelPlane === null} </div>
onClick={() => { <div className="stat-row">
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`); <span>Max fuel</span>
}} <strong>{e.maxFuel} L</strong>
> </div>
Refuel to full <div className="stat-row">
</button> <span>Max range</span>
</div> <strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
))}
</section> </section>
)}
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>} {actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside> </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 // 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 // (+ 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. // 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 = export type HoverTarget =
| { kind: 'plane'; id: string } | { kind: 'plane'; id: string }
| { kind: 'package'; originIcao: string; destIcao: string }; | { kind: 'package'; originIcao: string; destIcao: string; planeId?: string }
| { kind: 'route'; fromIcao: string; toIcao: string };
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pendingScaleConfirmed } from './LeftPane';
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
describe('pendingScaleConfirmed', () => {
it('is false while there is no pending value', () => {
expect(pendingScaleConfirmed(null, 1)).toBe(false);
expect(pendingScaleConfirmed(null, 64)).toBe(false);
});
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
expect(pendingScaleConfirmed(64, 1)).toBe(false);
expect(pendingScaleConfirmed(32, 8)).toBe(false);
});
it('becomes true exactly when a snapshot carries the pending scale', () => {
expect(pendingScaleConfirmed(64, 64)).toBe(true);
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
describe('queued route line color', () => {
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
});
});
+34 -23
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'; 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'; 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 // v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
// it is GROUNDED at the package's origin with a free slot (capacity from the // package only while it is GROUNDED at the package's origin with enough payload room for
// snapshot catalog; expired packages still occupy theirs). The Load button acts // it (maxPayloadKg from the snapshot catalog; expired packages still occupy theirs). The
// directly on the single eligible plane, or shows a picker when several qualify. // Load button acts directly on the single eligible plane, or shows a picker when several qualify.
const CATALOG = [ 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 { function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
@@ -24,6 +24,9 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
departureSimTime: null, departureSimTime: null,
lat: 50, lat: 50,
lon: 8.5, lon: 8.5,
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null,
queue: [],
...over, ...over,
}; };
} }
@@ -37,6 +40,9 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
reward: 1000, reward: 1000,
cargoType: 'mail', cargoType: 'mail',
cargoCount: 4, 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, deadlineSimTime: 1_000_000,
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -48,7 +54,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot { function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
return { return {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false }, simState: { clockMs: 0 },
planes, planes,
packages, packages,
catalog: CATALOG, catalog: CATALOG,
@@ -57,27 +63,27 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
}; };
} }
describe('slotsUsedFor', () => { describe('cargoWeightFor', () => {
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => { it('sums the weight of loaded AND expired packages bound to the plane (both occupy payload)', () => {
const s = snap( const s = snap(
[plane({ id: 'p1' })], [plane({ id: 'p1' })],
[ [
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }), pkg({ id: 'a', status: 'loaded', planeId: 'p1', weightKg: 800 }),
pkg({ id: 'b', status: 'expired', planeId: 'p1' }), pkg({ id: 'b', status: 'expired', planeId: 'p1', weightKg: 500 }),
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane pkg({ id: 'c', status: 'open', planeId: null, weightKg: 9_000 }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed 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', () => { 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)', () => { 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([ const s = snap([
plane({ id: 'at-origin' }), // eligible plane({ id: 'at-origin' }), // eligible
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport 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']); 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)', () => { it('a plane with partial payload can still load a package that fits', () => {
const packages = Array.from({ length: 6 }, (_, i) => const s = snap([plane({ id: 'partial' })], [pkg({ id: 'onboard', status: 'loaded', planeId: 'partial', weightKg: 3_000 })]);
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }), 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); const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); 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 = [ const packages = [
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })), ...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 960 })),
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot pkg({ id: 'exp', status: 'expired', planeId: 'full', weightKg: 960 }), // occupies its payload — 5 × 960 = 4800
]; ];
const s = snap([plane({ id: 'full' })], packages); const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
@@ -113,7 +124,7 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); 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' })]); const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); 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: volumes:
- "./public:/app/public" - "./public:/app/public"
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds. # 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" - "./db:/app/db"
environment: environment:
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and # e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
+118 -77
View File
@@ -1,21 +1,25 @@
import type { Page, Request } from '@playwright/test'; import type { Page, Request } from '@playwright/test';
import { expect, test } 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): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
// via the right pane → click its destination in the on-board list → 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 // arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
// penalty while grounded; expired packages deliver/abandon with no reward and no // penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions. // penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) 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_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_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005; const REP_PER_KM = 0.0005;
@@ -36,7 +40,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>; planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
packages: Array<{ packages: Array<{
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number; id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
@@ -52,24 +56,60 @@ async function getState(page: Page): Promise<StateBody> {
} }
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. // 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]> { // weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } }); // 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); expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number]; return (await res.json()) as StateBody['packages'][number];
} }
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> { // Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
const res = await page.request.post('/api/sim', { data: body }); // endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
expect(res.ok()).toBeTruthy(); 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 // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// resets the live instance's controls mid-test, fail loudly here instead of timing out later. // if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> { 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/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
} }
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both // Closest airport to the hub from /api/airports → shortest possible flight. Returns both
@@ -89,10 +129,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon }; return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
} }
// Every spec that touches the time controls restores scale=1 / unpaused on exit so // Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
// a failure mid-test can't leave the live instance fast-forwarded for later specs. // a failure mid-test can't leave the live instance fast-forwarded for later specs.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
}); });
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
@@ -100,7 +140,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 // (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. // exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> { 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')!; let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) { if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -117,6 +161,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!; plane = s.planes.find((p) => p.id === 'plane-starter')!;
} }
if (plane.locationIcao !== HUB) { if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
@@ -135,7 +181,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('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. // Self-heal: the starter plane may be anywhere from a previous run/spec.
await flyHomeViaApi(page); await flyHomeViaApi(page);
const before = await getState(page); const before = await getState(page);
@@ -167,8 +213,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. // package shows as loaded on the starter in the on-board section.
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible(); 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}`); const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled(); await expect(flyBtn).toBeEnabled();
await flyBtn.click(); await flyBtn.click();
@@ -205,23 +255,26 @@ 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; 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); 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 entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo( 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, 6,
); );
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly. // ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`); // exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
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);
// 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), at SCALE.
await setScale(page, SCALE);
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy(); expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
@@ -232,9 +285,10 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
await flyHomeViaApi(page); await flyHomeViaApi(page);
const before = await getState(page); const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI. // Two packages at the hub — load both, abandon one through the UI. Forced weights keep
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000); // the pair under the starter's payload (v0.27.0) so both loads are deterministic.
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000); 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]) { for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
@@ -267,26 +321,36 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page); await flyHomeViaApi(page);
const before = await getState(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must // A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's // unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
// destination differs from A's so it stays bound on the plane at LEMD arrival and can // 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 // still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead). // unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline 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); const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000);
for (const p of [pkgA, pkgB]) { for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
} }
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). // Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
await setScale(page, SCALE); // put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
let s = await getState(page); // baseline below is captured AFTER deactivateWorld, not before it.
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired'); await activateWorld(page);
s = await getState(page); try {
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired'); // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
} finally {
await deactivateWorld(page);
}
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
let s = before;
// Fly to the destination — arrival unbinds A for free (no reward, no rep). // Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } }); const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
@@ -308,15 +372,16 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home (scale is still SCALE from the expiry wait above). // Restore: top up + fly home at SCALE.
await page.request.post('/api/planes/plane-starter/refuel'); await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy(); expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}); });
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { 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); await flyHomeViaApi(page);
// Load a package whose origin the plane is not at → 409. // Load a package whose origin the plane is not at → 409.
@@ -347,40 +412,16 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
res = await page.request.post(`/api/packages/${pkg.id}/abandon`); res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409); 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, at SCALE.
await setScale(page, SCALE);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB); 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 — // ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the // auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
// hub leaves too little for ANOTHER long leg: the second outbound must 409. // insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
const s0 = await getState(page); // app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
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');
}); });
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R). // Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
+5 -3
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); 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 twelve endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
@@ -26,10 +26,12 @@ test('GET /api/openapi serves the spec with all ten endpoints and the package.js
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/refuel', '/api/planes/{planeId}/queue',
'/api/sim', '/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
+385 -24
View File
@@ -6,11 +6,18 @@ import { test, expect, type Page } from '@playwright/test';
// highlight layers (planes-highlight / routes-highlight / airports-highlight), each filtered // 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 // 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. // 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 SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
const DEST = 'LEMD'; 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 { interface PlaneState {
id: string; id: string;
@@ -20,6 +27,8 @@ interface PlaneState {
maxFuel: number; maxFuel: number;
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
lon: number;
} }
interface PackageState { interface PackageState {
@@ -33,7 +42,7 @@ interface PackageState {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
} }
@@ -44,23 +53,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> { 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/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -72,6 +76,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
return (await created.json()) as PackageState; 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. // Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> { async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page); const s0 = await getState(page);
@@ -89,6 +101,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); 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) expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect await expect
@@ -110,12 +124,30 @@ async function waitForAirportCount(page: Page): Promise<void> {
} }
// Rendered features of the given layers in the current viewport, [] until the map exists. // 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[]) => { return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap; const m = (window as any).__flightMap;
if (!m) return []; if (!m) return [];
try { 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 { } catch {
return []; return [];
} }
@@ -139,6 +171,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
if (!f) throw new Error(`airport ${icao} not found in /api/airports`); if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number]; return [f.properties.lon, f.properties.lat] as [number, number];
}); });
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
for (let i = 1; i < points.length; i++) {
let lon = points[i]![0];
const prev = points[i - 1]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
points[i]![0] = lon;
}
await page.evaluate((pts: [number, number][]) => { await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap; const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready'); if (!m) throw new Error('map not ready');
@@ -157,6 +199,61 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
await page.waitForTimeout(300); 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)]);
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
// (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
const near = (a: [number, number], b: [number, number]) => {
const dLon = Math.abs(a[0] - b[0]);
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
};
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
// 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('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 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 await flyHomeViaApi(page); // deterministic: the starter sits at EDDF, inside the initial view
@@ -227,6 +324,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
await fitToAirports(page, [HUB, DEST]); await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0);
await row.hover(); await row.hover();
await expect.poll(async () => { await expect.poll(async () => {
@@ -235,20 +333,182 @@ 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' }) }, { timeout: 15_000, message: 'waiting for the airports-highlight layer to show both endpoints' })
.toBe(JSON.stringify([HUB, DEST].sort())); .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 unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, { await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
timeout: 15_000, timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave', message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0); }).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 }) => { // ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
await fitToAirports(page, ['NZAA', 'NFTF']);
await row.hover();
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false; // none yet — keep polling
for (const s of segs) {
const c = s.geometry.coordinates;
for (let i = 1; i < c.length; i++) {
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
}
}
return true;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// 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 test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank. // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
const refuel = await page.request.post('/api/planes/plane-starter/refuel'); await setFuelFull(page, 'plane-starter');
expect(refuel.status()).toBe(200);
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -261,13 +521,14 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands. // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('onboard-plane-starter'); const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as // Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused. // e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
await postSim(page, { paused: true }); // the route line stays drawn while frozen.
await setScale(page, 1);
await block.hover(); await block.hover();
await expect.poll(async () => { await expect.poll(async () => {
@@ -283,7 +544,7 @@ test('hovering an on-board block of a flying plane highlights its route', async
// Resume and fast-forward to arrival, then fly home so the live instance stays in its // Resume and fast-forward to arrival, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. // onboarding state (starter grounded at EDDF) for the other specs.
await postSim(page, { timeScale: SCALE, paused: false }); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -291,6 +552,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}` }) }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true); .toBe(true);
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
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. At
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
await setScale(page, 1);
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// 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);
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
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect await expect
+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; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
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 }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
catalog: CatalogEntry[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): 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) {
// Make sure the world rate is back at SCALE for the return leg.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
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 the fixed world rate for the other specs (house convention)
// and leave a clean starter — full health, no active repair gate.
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
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);
});
+37 -28
View File
@@ -14,7 +14,8 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits // land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
// the 6300 L tank, so a full-tank departure always makes it home. // the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD'; const DEST = 'LEMD';
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface PlaneState { interface PlaneState {
id: string; id: string;
@@ -40,7 +41,7 @@ interface PackageState {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
} }
@@ -64,23 +65,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> { 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/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -92,6 +88,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
return (await created.json()) as PackageState; 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 // 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. // 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> { async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
@@ -197,10 +201,8 @@ test('plane markers move while flying and the plane popup shows status + route',
await loadAndFly(page, starterLoc, HUB); await loadAndFly(page, starterLoc, HUB);
} }
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank, // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
// so a partially drained plane left by an earlier spec could not make it home. await setFuelFull(page, 'plane-starter');
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet. // 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
const pkg = await createPackage(page, HUB, DEST); const pkg = await createPackage(page, HUB, DEST);
@@ -228,7 +230,7 @@ test('plane markers move while flying and the plane popup shows status + route',
// 2) Fly the plane and speed up — its current leg must appear as a route line. // 2) Fly the plane and speed up — its current leg must appear as a route line.
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await postSim(page, { timeScale: SCALE }); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
@@ -251,23 +253,30 @@ test('plane markers move while flying and the plane popup shows status + route',
// The arc itself stays covered by unit tests: lib/mapData.test.ts.) // The arc itself stays covered by unit tests: lib/mapData.test.ts.)
// 4) Click the flying plane → status popup with model, status, fuel and the leg. // 4) Click the flying plane → status popup with model, status, fuel and the leg.
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered // Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift // our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
// on this leg. Pausing makes the position static; we resume right after the popup checks. // view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
await postSim(page, { paused: true }); // we restore SCALE right after the popup checks.
await setScale(page, 1);
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
// would show (~km per poll pair).
let prevPos: { lat: number; lon: number } | null = null; let prevPos: { lat: number; lon: number } | null = null;
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false; if (!p || p.status !== 'flying') return false;
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true; if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
prevPos = { lat: p.lat, lon: p.lon }; prevPos = { lat: p.lat, lon: p.lon };
return false; return false;
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' }) }, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true); .toBe(true);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused // Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
// above the server's analytic position is static — but the map renders from its own ~1 s // above the server's analytic position barely moves — but the map renders from its own ~1 s
// poll, so right after the freeze confirmation the last rendered frame can still be up to // poll, so right after the freeze confirmation the last rendered frame can still be up to
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected // one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's // from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
@@ -307,8 +316,8 @@ test('plane markers move while flying and the plane popup shows status + route',
// The popup's route line is the plane's own leg: origin name → destination name. // The popup's route line is the plane's own leg: origin name → destination name.
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`); await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Resume before step 5 — the landing wait needs a running clock. // Restore SCALE before step 5 — the landing wait needs a fast clock.
await postSim(page, { timeScale: SCALE, paused: false }); await setScale(page, SCALE);
// 5) Let the flight finish, then fly home so the live instance stays in its // 5) Let the flight finish, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. The loaded // onboarding state (starter grounded at EDDF) for the other specs. The loaded
+12 -7
View File
@@ -22,7 +22,7 @@ async function waitForAirportCount(page: Page): Promise<number> {
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10); 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 page.goto(APP_URL);
await expect(page.getByTestId('map')).toBeVisible(); await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page); 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); await page.goto(APP_URL);
const header = page.getByTestId('app-header'); const header = page.getByTestId('app-header');
await expect(header).toContainText(`v${pkg.version}`); 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 }) => { 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); ).toBeGreaterThan(0);
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it. // 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 // Nantes is used because the v0.9.0 three-pane layout narrows the map column (~560 px at the
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center) // default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its // isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot // LFBD Bordeaux, ~39 px away at the initial zoom [10,52] z4 (verified against data/airports_world.csv),
// be overlapped by a neighbouring circle. // so its marker cannot be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => { const pt = await page.evaluate(() => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why 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 // 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 — // source/layer not in the new style, so the airports layer must be re-established —
// count still shown and markers rendered again. // 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); await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Clicking a marker after the switch opens exactly one popup (the click handler is // Clicking a marker after the switch opens exactly one popup (the click handler is
+124 -73
View File
@@ -1,18 +1,16 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time // The panes render from the shell's single /api/state poll (lib/useGameState.ts).
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
interface SimState { const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
clockMs: number; const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
timeScale: number; const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
paused: boolean; const STARTER = 'plane-starter';
}
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: SimState; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>; packages: Array<Record<string, unknown>>;
} }
@@ -23,11 +21,70 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Leave the live instance in a sane state for the other specs (scale 1, running) — same // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
// and repairPlane 409s on non-available planes, which would break activateWorld.
async function flyHomeViaApi(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before acting on the starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
}
if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
}
}
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
// pattern as e2e/sim.spec.ts. // pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => { test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
@@ -60,79 +117,68 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
await expect(row).toContainText(truth.locationName); 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'); const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible(); 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). // Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); 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);
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
}); });
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => { test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
await page.goto('/'); test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
const pauseBtn = page.getByTestId('pause-toggle'); // Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
await expect(pauseBtn).toBeVisible({ timeout: 15_000 }); // 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
await activateWorld(page);
try {
await page.goto('/');
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
// Pause via the UI button — assert against the authoritative /api/state… // "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
await pauseBtn.click(); const sample = async () => {
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true); const text = ((await clock.textContent()) ?? '').trim();
// …and in the pane itself (the refetch after POST flips the button to Resume). expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
await expect(pauseBtn).toHaveText('Resume'); return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates // Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
// optimistically on input, and release flushes the debounced POST immediately. // so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
const slider = page.getByTestId('time-scale-slider'); // rendering a sample pair could straddle zero snapshot updates and show no change at all.
await slider.focus(); const t0 = await sample();
await page.keyboard.press('End'); await new Promise((r) => setTimeout(r, 1_300));
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 }); const t1 = await sample();
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000); await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
// Home jumps to min (t=0 → 1×). expect(t1).toBeGreaterThan(t0);
await page.keyboard.press('Home'); expect(t2).toBeGreaterThan(t1);
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 }); // Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1); // still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC. expect(total).toBeGreaterThanOrEqual(50_000);
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/); expect(total).toBeLessThanOrEqual(200_000);
} finally {
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too). await deactivateWorld(page);
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);
});
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
const slider = page.getByTestId('time-scale-slider');
await expect(slider).toBeVisible({ timeout: 15_000 });
// Commit to max via keyboard (input + change → immediate commitNow POST)…
await slider.focus();
await page.keyboard.press('End');
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
const seen: string[] = [];
for (let i = 0; i < 30; i++) {
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
await new Promise((r) => setTimeout(r, 25));
} }
expect(
seen.every((v) => v === '1000×'),
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
).toBe(true);
// …and the server confirms it.
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
// Restore to min for later tests (afterEach restores scale anyway).
await page.keyboard.press('Home');
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
}); });
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => { test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
@@ -140,4 +186,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
const right = page.getByTestId('right-pane'); const right = page.getByTestId('right-pane');
await expect(right).toBeVisible(); await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market'); 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');
}); });
+603
View File
@@ -0,0 +1,603 @@
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; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
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 }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
// Test 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) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
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 setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
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);
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
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 (scale 1) so the ~10 s leg can't land under us while we assert.
await setScale(page, 1);
await page.goto('/');
await waitForAirportCount(page);
// 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);
// Re-set SCALE for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
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
});
+270
View File
@@ -0,0 +1,270 @@
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). Since v0.49.0 (ISS-096) the popup row also
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
// 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 the world rate and VERIFY from the response that it stuck —
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// 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) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
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 the fixed world rate for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await setScale(page, WORLD);
});
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);
});
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
// shortest leg takes minutes of real time) — "still flying its original destination" below is
// deterministic regardless of how fast the UI moves.
const nearest = await nearestToHub(page);
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
await setScale(page, 1);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
await page.goto('/');
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}, { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () =>
page.evaluate(() => {
const m = (window as any).__flightMap;
if (!m || !m.getSource('planes')) return false;
try {
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
} catch {
return false; // source not ready yet — keep polling
}
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, POPUP_AIRPORT);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText(POPUP_AIRPORT.icao);
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
// is not grounded; while flying its option shows the DEPARTURE airport.
const planeSel = page.getByTestId('airport-popup-plane');
await expect(planeSel).toBeVisible();
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
const goBtn = page.getByTestId('airport-popup-relocate');
await expect(goBtn).toBeDisabled(); // placeholder selected
await planeSel.selectOption('plane-starter');
await expect(goBtn).toBeEnabled();
await goBtn.click();
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
// the flight queue while the plane keeps flying its original destination.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
});
+210 -135
View File
@@ -1,14 +1,25 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } 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, guaranteedRangeKm, windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same // (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews // abandon buttons (penalty preview from the same
// match the catalog + economy constants exposed in /api/state. Packages are created via // 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. // POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
interface PlaneState { interface PlaneState {
@@ -17,6 +28,7 @@ interface PlaneState {
status: 'available' | 'flying'; status: 'available' | 'flying';
fuel: number; fuel: number;
maxFuel: number; maxFuel: number;
health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
} }
@@ -26,10 +38,11 @@ interface PackageState {
originName: string; originName: string;
destName: string; destName: string;
reward: number; 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; planeId: string | null;
createdSimTime: number; createdSimTime: number;
deadlineSimTime: number; deadlineSimTime: number;
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
} }
interface CatalogEntry { interface CatalogEntry {
@@ -40,12 +53,12 @@ interface CatalogEntry {
burnPerKm: number; burnPerKm: number;
emptyWeightKg: number; // v0.16.0 weighted burn input emptyWeightKg: number; // v0.16.0 weighted burn input
speedKmh: number; speedKmh: number;
capacity: number; maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
} }
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
catalog: CatalogEntry[]; catalog: CatalogEntry[];
@@ -62,23 +75,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> { 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/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats). // Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -100,7 +108,8 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
} }
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000. // 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 { const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>; features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
}; };
@@ -112,32 +121,7 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
nearest = f.properties; nearest = f.properties;
} }
} }
return nearest; return { ...nearest, hubLat: hub.lat, hubLon: hub.lon };
}
// 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 };
} }
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty — // Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
@@ -148,10 +132,11 @@ 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); 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. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> { // 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', { 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); expect(created.status()).toBe(201);
return (await created.json()) as PackageState; return (await created.json()) as PackageState;
@@ -159,7 +144,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. // Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> { 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')!; const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
if (starter.status === 'flying' || starter.locationIcao !== HUB) { if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -174,8 +163,11 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); // Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) // 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 await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000, timeout: 240_000,
@@ -183,6 +175,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
}) })
.toBe(true); .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 }) => { test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
@@ -198,13 +198,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(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/); await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').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). // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page); 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}"]`); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// 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)); 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}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
@@ -219,6 +226,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 }); 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('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 test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
@@ -244,9 +268,15 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 }); await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server). // The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value, // The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
// making the preview deterministic instead of racing the 1 s poll loop. // other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
await postSim(page, { paused: true }); // snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
const frozenClock = (await getState(page)).simState.clockMs;
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
expect(
(await getState(page)).simState.clockMs,
'world should be frozen while the fleet is idle (ISS-117)',
).toBe(frozenClock);
const state = await getState(page); const state = await getState(page);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs); const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty)); await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
@@ -256,8 +286,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled(); await expect(flyBtn).toBeEnabled();
await flyBtn.click(); await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg… // The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 }); await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter'); const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible(); await expect(leg).toBeVisible();
await expect(leg).toContainText('→'); await expect(leg).toContainText('→');
@@ -267,8 +297,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying'); expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao); expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
// Resume and fast-forward to arrival (poll the authoritative state). // Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
await postSim(page, { paused: false });
await setScale(page, SCALE); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
@@ -283,7 +312,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered'); expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 }); await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run. // Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200); expect(homeRes.status()).toBe(200);
await expect await expect
@@ -294,21 +324,33 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
.toBe(true); .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 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) 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) const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
expect(body.catalog.length).toBeGreaterThan(0); expect(body.catalog.length).toBeGreaterThan(0);
const entry = body.catalog.find((e) => e.model === 'atr72')!; 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('/'); 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'); const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 }); await expect(buyModel).toBeVisible({ timeout: 15_000 });
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label); 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(); await expect(page.getByTestId('market-buy')).toBeVisible();
// Sell: the grounded-plane picker lists plane-starter and the payout preview is // Sell: the grounded-plane picker lists plane-starter and the payout preview is
@@ -318,97 +360,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 }); await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
const payout = Math.round(entry.price * body.sellFraction * 100) / 100; const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout)); 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 }) => { // ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead. This test pins the new
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 // 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); await flyHomeViaApi(page);
const body = await getState(page); const body = await getState(page);
const entry = body.catalog.find((e) => e.model === 'atr72')!; const entry = body.catalog.find((e) => e.model === 'atr72')!;
// Deterministic baseline: a full tank. // Drop the grounded starter to a low, deterministic fuel level via the test hook — well below the
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); // 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 // Nearest airport to the hub → short leg, fast at scale 1000.
// exceeds what's left on arrival. const nearest = await nearestToHub(page);
const farthest = await farthestFlyableToHub(page, entry); const pre = await getState(page);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); const starter = pre.planes.find((p) => p.id === 'plane-starter')!;
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
// Fly there (delivers pkg1 on arrival) — the tank is now partial. // Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200); // 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 setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
const b = await getState(page); const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter'); const p = b.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === farthest.icao; return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` }) }, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` })
.toBe(true); .toBe(true);
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for. const landed = await getState(page);
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000); expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); 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). // Restore: fly home (auto-refuel covers the return leg) and top the tank back up for later specs.
const pre = await getState(page); await setScale(page, SCALE);
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.
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { .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', message: 'waiting for plane-starter to land back at the hub',
}) })
.toBe(true); .toBe(true);
// Top the tank back up so later specs start with a full tank. expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
expect((await page.request.post('/api/planes/plane-starter/refuel')).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 + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!;
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
const b777 = page.getByTestId('plane-type-b777');
await expect(b777).toBeVisible();
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
}); });
+123 -103
View File
@@ -1,157 +1,177 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a // v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS // fleet, background-tick advance at a raised rate while something is active, package expiry via
// hook enabled in docker-compose.yml), and market top-up. // the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts). // Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000; const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000 const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts. // Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10; const MARKET_TARGET = 10;
const STARTER = 'plane-starter';
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: SimState; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<{ id: string; status: string }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>; packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
} }
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); const res = await page.request.get('/api/state');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
test.afterEach(async ({ page }) => { async function setScale(page: Page, scale: number): Promise<void> {
const res = await postSim(page, { timeScale: 1, paused: false }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
}); });
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => { test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE }); const s = await getState(page);
expect(post.status()).toBe(200); expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const s1 = (await post.json()) as SimState; const frozen = s.simState.clockMs;
expect(s1.timeScale).toBe(SCALE);
expect(s1.paused).toBe(false);
let prev = s1.clockMs;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp await sleep(500);
const body = await getState(page); expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
const now = body.simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
} }
}); });
test('paused freezes the clock exactly across polls', async ({ page }) => { test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE, paused: true }); await activateWorld(page);
expect(post.status()).toBe(200); try {
const s1 = (await post.json()) as SimState; await setScale(page, SCALE);
expect(s1.paused).toBe(true);
for (let i = 0; i < 3; i++) { let prev = (await getState(page)).simState.clockMs;
await sleep(500); for (let i = 0; i < 3; i++) {
const body = await getState(page); await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused const now = (await getState(page)).simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
} finally {
await deactivateWorld(page);
} }
}); });
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => { test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
const before = await getState(page); // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
const victim = before.packages.find((p) => p.status === 'open'); // package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
expect(victim).toBeDefined(); // steady state always has open packages const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open await setScale(page, SCALE);
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
let expiredBody: StateBody | undefined; const after = expiredBody!;
await expect expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
.poll(async () => { // Expired packages leave the available set…
const body = await getState(page); expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body; // …and spawn tops the market back up to the target — never below it.
return !!expiredBody; const openCount = after.packages.filter((p) => p.status === 'open').length;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' }) expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
.toBe(true); } finally {
await deactivateWorld(page);
const after = expiredBody!; }
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
}); });
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => { test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await activateWorld(page);
try {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
const created = await page.request.post('/api/test/expire-package'); await setScale(page, SCALE);
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
let expiredBody: StateBody | undefined; const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
await expect expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
.poll(async () => { } finally {
const body = await getState(page); await deactivateWorld(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body; }
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
}); });
test('POST /api/sim validates and clamps its input', async ({ page }) => { test('POST /api/test/set-timescale validates its input', async ({ page }) => {
const clampHigh = await postSim(page, { timeScale: 2000 }); const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
expect(clampHigh.status()).toBe(200); expect(ok.status()).toBe(200);
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0) expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
const inRange = await postSim(page, { timeScale: 500 }); for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
expect(inRange.status()).toBe(200); const res = await page.request.post('/api/test/set-timescale', { data: body });
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
const clampLow = await postSim(page, { timeScale: 0.1 });
expect(clampLow.status()).toBe(200);
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await postSim(page, body);
expect(res.status()).toBe(400); expect(res.status()).toBe(400);
} }
const notJson = await page.request.post('/api/sim', { data: 'nope{' }); const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
expect(notJson.status()).toBe(400); expect(notJson.status()).toBe(400);
// Empty body is a valid no-op (just ticks + returns current state). // Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
const empty = await postSim(page, {}); const empty = await page.request.post('/api/test/set-timescale', { data: {} });
expect(empty.status()).toBe(200); expect(empty.status()).toBe(400);
}); });
+63 -14
View File
@@ -10,7 +10,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>; packages: Array<Record<string, unknown>>;
} }
@@ -24,9 +24,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(typeof body.account.cash).toBe('number'); expect(typeof body.account.cash).toBe('number');
expect(typeof body.account.reputation).toBe('number'); expect(typeof body.account.reputation).toBe('number');
expect(typeof body.simState.clockMs).toBe('number'); expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
expect(typeof body.simState.timeScale).toBe('number');
expect(body.simState.paused).toBe(false);
expect(Array.isArray(body.planes)).toBe(true); expect(Array.isArray(body.planes)).toBe(true);
expect(body.planes.length).toBeGreaterThanOrEqual(1); expect(body.planes.length).toBeGreaterThanOrEqual(1);
@@ -36,7 +34,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(Array.isArray(body.packages)).toBe(true); expect(Array.isArray(body.packages)).toBe(true);
expect(body.packages.length).toBeGreaterThan(0); 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); expect(body.packages[0], `package.${key}`).toHaveProperty(key);
} }
}); });
@@ -44,6 +42,17 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => { test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
.toBe('idle');
const resBefore = await page.request.get('/api/state'); const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200); expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody; const before = (await resBefore.json()) as StateBody;
@@ -68,17 +77,57 @@ test('state survives a container restart (sim clock + account persisted)', async
const a = after!; const a = after!;
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
// persisted across sessions"): account and entity ids are byte-identical. // persisted across sessions"): account and entity ids are byte-identical. With the fleet
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one // grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the expect(a.simState.clockMs).toBe(before.simState.clockMs);
// post-restart clock may be ahead — but never by more than that bound.
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
);
expect(a.account.cash).toBe(before.account.cash); expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation); expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.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()); 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 });
}
});
+542
View File
@@ -0,0 +1,542 @@
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, 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; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
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 }; // ISS-117: the snapshot carries only the game clock
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;
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// 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
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
await setScale(page, SCALE);
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => {
const s = await getState(page);
return s.planes.find((x) => x.id === planeId)?.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) {
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
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)
await setScale(page, WORLD);
});
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 and both deliver their packages', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// 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 (a shared-tick arrival would race the first-landing assert).
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);
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);
const landed = await getState(page);
// 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);
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
});
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
// 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]);
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 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([]);
// …fast-forward the chained leg to its landing.
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);
});
+9
View File
@@ -0,0 +1,9 @@
import { startBackgroundTick } from '@/lib/sim/engine';
// ISS-117 (v0.52.0): the world clock advances on a ~1 s background tick even with nobody polling —
// started once at server boot (Next ≥15 instrumentation hook; runs under `next dev` and `next start`).
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
startBackgroundTick();
}
}
+22 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports'; import { assertUniqueIcaos, filterWorldAirports, parseCsv, toGeoJson, airportsHandler } from './airports';
const HEADER = const HEADER =
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"'; '"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
@@ -53,25 +53,34 @@ describe('parseCsv', () => {
}); });
}); });
describe('filterEuAirports', () => { describe('filterWorldAirports (v0.44.0 — world dataset, no continent check)', () => {
it('keeps EU fixed-wing airports and seaplane bases, drops the rest', () => { it('keeps fixed-wing airports and seaplane bases from every continent, drops the rest by type', () => {
const rows = parseCsv( const rows = parseCsv(
[ [
HEADER, HEADER,
row({ ident: 'EU-SMALL', type: 'small_airport' }), row({ ident: 'EU-SMALL', type: 'small_airport' }),
row({ ident: 'EU-MED', type: 'medium_airport' }), row({ ident: 'NA-LRG', continent: 'NA', type: 'large_airport' }),
row({ ident: 'EU-LRG', type: 'large_airport' }), row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
row({ ident: 'EU-SEA', type: 'seaplane_base' }), row({ ident: 'EU-SEA', type: 'seaplane_base' }),
row({ ident: 'EU-LEGACY', type: 'airport' }), row({ ident: 'EU-LEGACY', type: 'airport' }),
row({ ident: 'EU-Helio', type: 'heliport' }), row({ ident: 'NA-Helio', continent: 'NA', type: 'heliport' }),
row({ ident: 'EU-CLOSED', type: 'closed' }), row({ ident: 'AS-CLOSED', continent: 'AS', type: 'closed' }),
row({ ident: 'EU-BALLOON', type: 'balloonport' }),
row({ ident: 'NA-SMALL', continent: 'NA' }),
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
].join('\n'), ].join('\n'),
); );
const kept = filterEuAirports(rows).map((r) => r.ident); const kept = filterWorldAirports(rows).map((r) => r.ident);
expect(kept.sort()).toEqual(['EU-LEGACY', 'EU-LRG', 'EU-MED', 'EU-SEA', 'EU-SMALL']); 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', () => { 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(); const res = await airportsHandler();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json'); expect(res.headers.get('content-type')).toContain('application/json');
+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 // 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 // single "airport" type + category column) — both spellings are accepted. Heliports are
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) 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']); const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
export function filterEuAirports(rows: CsvRow[]): CsvRow[] { export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
return rows.filter((r) => r.continent === 'EU' && INCLUDED_TYPES.has(r.type)); return rows.filter((r) => INCLUDED_TYPES.has(r.type));
} }
export interface AirportProperties { export interface AirportProperties {
@@ -93,13 +95,26 @@ export function toGeoJson(rows: CsvRow[]): GeoJsonFeatureCollection {
return { type: 'FeatureCollection', features }; 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; let cached: GeoJsonFeatureCollection | null = null;
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> { export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
if (cached) return cached; 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'); const text = await fs.readFile(file, 'utf8');
cached = toGeoJson(filterEuAirports(parseCsv(text))); const fc = toGeoJson(filterWorldAirports(parseCsv(text)));
assertUniqueIcaos(fc);
cached = fc;
return cached; return cached;
} }
+10 -3
View File
@@ -3,6 +3,8 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import initSqlJs from 'sql.js'; import initSqlJs from 'sql.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CLOCK_MS } from '@/lib/game/seed';
import { openGameDb, persist } from './client'; import { openGameDb, persist } from './client';
import * as schema from './schema'; import * as schema from './schema';
@@ -111,8 +113,9 @@ describe('openGameDb', () => {
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0); expect(account?.cash).toBeGreaterThan(0);
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim?.paused).toBe(false); // ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
expect(sim?.timeScale).toBe(1); expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
expect(sim?.timeScale).toBe(WORLD_TIME_SCALE);
// The seed was durably written: the file exists and is non-empty. // The seed was durably written: the file exists and is non-empty.
const st = await fs.stat(file); const st = await fs.stat(file);
@@ -151,7 +154,8 @@ describe('openGameDb', () => {
raw.exec(LEGACY_DDL); 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 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 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'); const file = path.join(dir, 'legacy.db');
await fs.writeFile(file, Buffer.from(raw.export())); await fs.writeFile(file, Buffer.from(raw.export()));
@@ -167,6 +171,9 @@ describe('openGameDb', () => {
// …and the ALTER-default 0 was fixed up to a real timestamp so the first // …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. // tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
expect(sim?.lastRealTickMs).toBeGreaterThan(0); 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 () => { it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
+38 -3
View File
@@ -30,7 +30,10 @@ CREATE TABLE IF NOT EXISTS planes (
dest_icao TEXT, dest_icao TEXT,
lat REAL NOT NULL, lat REAL NOT NULL,
lon 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 ( CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
@@ -39,6 +42,8 @@ CREATE TABLE IF NOT EXISTS packages (
reward REAL NOT NULL, reward REAL NOT NULL,
cargo_type TEXT NOT NULL, cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL, cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
special TEXT,
deadline_sim_time INTEGER NOT NULL, deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
plane_id TEXT, plane_id TEXT,
@@ -52,7 +57,8 @@ CREATE TABLE IF NOT EXISTS sim_state (
clock_ms INTEGER NOT NULL, clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL, time_scale REAL NOT NULL,
paused INTEGER 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')) { if (!cols('planes').has('dest_icao')) {
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT'); 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 // v0.12.0 one-time migration: contracts world → packages world. Detected by the
@@ -133,6 +165,7 @@ async function open(file: string): Promise<GameDb> {
// First-run seed: only when the DB is empty (no account row yet). // First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account'); const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance); await seedEmpty(instance);
} }
@@ -159,7 +192,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
const data = buildSeed({ airports, rng, nowMs: Date.now() }); 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.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account); await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState); 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', { export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(), id: text('id').primaryKey().notNull(),
model: text('model').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(), fuel: real('fuel').notNull(),
maxFuel: real('max_fuel').notNull(), maxFuel: real('max_fuel').notNull(),
locationIcao: text('location_icao').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 // flying, `fuel` is frozen at its departure value — live position/fuel are pure
// functions of the clock (lib/sim/movement.ts), recomputed on read. // functions of the clock (lib/sim/movement.ts), recomputed on read.
departureSimTime: integer('departure_sim_time'), 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 // 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(), reward: real('reward').notNull(),
cargoType: text('cargo_type').notNull(), cargoType: text('cargo_type').notNull(),
cargoCount: integer('cargo_count').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(), deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded // 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free. // expired packages keep occupying their slot, deliver no reward, and abandon free.
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'), planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(), 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 // Real-time (Date.now()) of the last tick — the tick-on-read engine advances
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick. // clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
lastRealTickMs: integer('last_real_tick_ms').notNull(), 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; 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 },
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 } });
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 { 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', () => { describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => { 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', () => { describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { 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'); 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`; 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 // 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. // seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string { export function formatSimClock(clockMs: number): string {
+407 -155
View File
@@ -6,17 +6,32 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client'; import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { HUB_ICAO } from './seed'; 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, STARTING_CLOCK_MS } from './seed';
import { import {
abandonPackage, abandonPackage,
buyPlane, buyPlane,
cancelQueuedLeg,
flyPlane, flyPlane,
GameActionError, GameActionError,
ignorePackage,
loadPackage, loadPackage,
refuelPlane, queueLeg,
repairPlane,
sellPlane, sellPlane,
} from './actions'; } from './actions';
@@ -125,27 +140,27 @@ describe('loadPackage', () => {
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/); 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'); const inst = await freshDb('load-full.db');
await insertOpenPackage(inst, 'p-new'); await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Fill all atr72 slots with loaded cargo… // Fill past maxPayloadKg with loaded cargo…
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) { const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); 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'); const inst = await freshDb('load-expiredslot.db');
await insertOpenPackage(inst, 'p-new'); await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// capacity − 1 loaded + 1 expired = full. // Half + half (expired) leaves no room — each alone would fit.
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) { const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
} await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
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);
}); });
}); });
@@ -158,7 +173,7 @@ describe('flyPlane', () => {
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD'); expect(plane.destIcao).toBe('LEMD');
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), no tick in direct calls
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
expect(plane.lat).toBe(origin.lat); expect(plane.lat).toBe(origin.lat);
@@ -186,23 +201,247 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
}); });
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => { it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter')); 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);
}); });
}); });
describe('abandonPackage', () => { describe('abandonPackage', () => {
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0. // A loaded package created at the seeded game clock with a 10h window (ISS-117).
const REWARD = 1_000; const REWARD = 1_000;
const WINDOW_MS = 10 * 3_600_000; const WINDOW_MS = 10 * 3_600_000;
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') { async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
const inst = await freshDb(name); const inst = await freshDb(name);
// Always insert OPEN and load it through the real action… // Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS }); await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
await loadPackage(inst, 'p-ab', 'plane-starter'); await loadPackage(inst, 'p-ab', 'plane-starter');
// …then flip to expired in place (what the tick would do once the deadline passes). // …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') { if (status === 'expired') {
@@ -227,7 +466,7 @@ describe('abandonPackage', () => {
it('scales to exactly 37.5% at the midpoint of the window', async () => { it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db'); const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab');
@@ -236,8 +475,8 @@ describe('abandonPackage', () => {
it('caps at exactly 60% at and past the deadline', async () => { it('caps at exactly 60% at and past the deadline', async () => {
for (const [name, clock] of [ for (const [name, clock] of [
['ab-deadline.db', T0 + WINDOW_MS], ['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline ['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
] as const) { ] as const) {
const inst = await setupLoaded(name); const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock }); await inst.db.update(schema.simState).set({ clockMs: clock });
@@ -291,6 +530,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', () => { describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db'); const inst = await freshDb('buy-ok.db');
@@ -325,6 +594,38 @@ describe('buyPlane', () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); await expectActionError(buyPlane(inst, 'atr72'), 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', () => { describe('sellPlane', () => {
@@ -357,6 +658,32 @@ describe('sellPlane', () => {
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); 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 () => { it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
@@ -368,156 +695,81 @@ describe('sellPlane', () => {
}); });
}); });
describe('refuelPlane', () => { describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => { it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
const inst = await freshDb('refuel-ok.db'); const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await 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]; 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(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0]; const 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 () => { it('is a free no-op when health is already full', async () => {
const inst = await freshDb('refuel-full.db'); // seeded starter is full const inst = await freshDb('repair-full.db'); // seeded starter at 100
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await 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); expect(cost).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
}); });
it('rejects a flying plane with 409', async () => { 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) await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(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 () => { it('rejects a plane that is already repairing with 409', async () => {
const inst = await freshDb('refuel-poor.db'); const inst = await freshDb('repair-busy.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes)
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960 .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 () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db'); const inst = await freshDb('repair-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404); await expectActionError(repairPlane(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,
);
}); });
}); });
+182 -83
View File
@@ -3,24 +3,36 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client'; import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot } from './state'; import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; 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 { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed'; import { HUB_ICAO } from './seed';
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side: // 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 // load a package onto a grounded plane at its origin, fly to any airport (dispatch
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and // auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance // 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 + // (persisted at the end); routes wrap them in engine.runAction() so tick +
// validate + mutate are atomic under the sim lock. Validation failures throw // validate + mutate are atomic under the sim lock. Validation failures throw
// GameActionError carrying the HTTP status: // GameActionError carrying the HTTP status:
// 404 — unknown entity id // 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane // 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no 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) // 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error { 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}) --- // --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's // open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired // origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// cargo keeps occupying its space until delivered/abandoned). // 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> { export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([ const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)), inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
@@ -69,13 +82,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
const type = PLANE_TYPES[plane.model]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const occupied = ( const occupied = await inst.db.select({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
await inst.db.select().from(schema.packages).where( and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)), );
) const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
).length; if (usedKg + pkg.weightKg > type.maxPayloadKg) {
if (occupied >= type.capacity) { throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
} }
await inst.db.update(schema.packages) 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 // 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 // loaded packages' destinations or pure repositioning. On arrival the engine
// delivers whatever is bound for that airport. Validation: plane 'available', // 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> { 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 planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
@@ -97,6 +114,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
} }
if (plane.health <= 0) {
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
}
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const destFeature = fc.features.find((f) => f.properties.icao === destIcao); 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]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon); 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. // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); // adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
if (requiredL > plane.fuel) { const w = weatherFor(plane.locationIcao);
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`); 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. // runAction() ticked first, so this is current game time.
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
// 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) await inst.db.update(schema.planes)
.set({ .set({
status: 'flying', status: 'flying',
@@ -123,8 +166,64 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
departureSimTime: sim.clockMs, departureSimTime: sim.clockMs,
lat: origin.lat, lat: origin.lat,
lon: origin.lon, 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)); .where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
await 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); await persist(inst);
} }
@@ -162,18 +261,38 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
return { penalty }; return { penalty };
} }
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with
// a new random package on the next tick. 404 unknown id; 409 unless open.
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
}
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
await persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) --- // --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at // Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// the home base (EDDF). // reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> { export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model]; const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing'); if (!account) throw new Error('account row missing');
if (account.cash < type.price) { // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`); 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(); const fc = await loadAirportsGeoJson();
@@ -192,8 +311,9 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
lat: home.lat, lat: home.lat,
lon: home.lon, lon: home.lon,
departureSimTime: null, 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); await persist(inst);
return { planeId }; return { planeId };
} }
@@ -210,6 +330,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`); 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); const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
@@ -225,74 +359,39 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
return { 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 // Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
// enough fuel for one loaded package's destination leg — required liters per the // Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
// weighted burn model, independent of how much is already in the tank. // over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
export interface RefuelOptions { // and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
mode?: 'full' | 'forFlight'; // planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
packageId?: string; // plane; 409 not grounded / insufficient funds.
} export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
// 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 }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') { 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'; const sim = (await inst.db.select().from(schema.simState))[0];
let targetFuel: number; // tank level after the refuel if (!sim) throw new Error('sim_state row missing');
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 account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing'); if (!account) throw new Error('account row missing');
if (cost > 0 && account.cash < cost) { if (account.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); 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)); // Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
if (cost > 0) { await inst.db.update(schema.planes)
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); .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); await persist(inst);
return { cost }; return { cost: REPAIR_COST };
} }
// --- Route helper (response convention, v0.8.0) --- // --- Route helper (response convention, v0.8.0) ---
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { STARTING_REPUTATION } from './seed';
import { guaranteedRangeKm, MAX_WIND_BURN_FACTOR, maxRangeKm, requiredFuelFor } from '@/lib/sim/movement';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
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);
});
});
// v0.51.0 (ISS-099) — max range consistency: for EVERY catalog type the displayed guaranteed range
// must be flyable on a full tank under worst-case headwind burn (the same requiredFuelFor ≤ maxFuel
// rule as flyPlane's fuel guard), and tight — not padded. The closed form inverts exactly, so the
// "≤" side allows only float noise (~1e-11 L → relative 1e-9 tolerance).
describe('max range consistency (v0.51.0, ISS-099)', () => {
for (const t of Object.values(PLANE_TYPES)) {
it(`${t.id}: guaranteed range is flyable in worst-case headwind and not padded`, () => {
const r = guaranteedRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, r))
.toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
// Tight: 5% beyond the displayed range no longer fits a full tank.
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, 1.05 * r))
.toBeGreaterThan(t.maxFuel);
});
it(`${t.id}: the no-wind closed form inverts requiredFuelFor exactly`, () => {
const r = maxRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(requiredFuelFor(t.burnPerKm, t.emptyWeightKg, t.maxFuel, r)).toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
});
}
});
+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 // 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. // 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 { export interface PlaneType {
id: string; id: string;
@@ -16,8 +20,10 @@ export interface PlaneType {
// Market price in € (v0.8.0): buying costs `price`, selling pays out // Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price. // SELL_FRACTION × price.
price: number; price: number;
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane. // Max payload in kg (v0.27.0, ISS-045 — replaces the v0.12.0 slot count): total
capacity: number; // 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> = { 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), // €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. // a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000, 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. // The plane the player starts with.
export const STARTER_MODEL = 'atr72'; 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; export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0). // Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5; 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 // 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 // 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 { export interface CatalogEntry {
model: string; model: string;
label: string; label: string;
@@ -57,7 +146,8 @@ export interface CatalogEntry {
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L. // lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
emptyWeightKg: number; emptyWeightKg: number;
speedKmh: 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 // 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, burnPerKm: t.burnPerKm,
emptyWeightKg: t.emptyWeightKg, emptyWeightKg: t.emptyWeightKg,
speedKmh: t.speedKmh, speedKmh: t.speedKmh,
capacity: t.capacity, maxPayloadKg: t.maxPayloadKg,
})); }));
} }
+245 -20
View File
@@ -1,18 +1,27 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo'; 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 { import {
BASE_REWARD, BASE_REWARD,
buildSeed, buildSeed,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX, PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN, PACKAGE_COUNT_MIN,
generatePackage, generatePackage,
HUB_ICAO, HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
mulberry32, mulberry32,
REWARD_PER_KG,
REWARD_PER_KM, REWARD_PER_KM,
rewardForDistanceKm, rewardFor,
STARTING_CASH, STARTING_CASH,
STARTING_CLOCK_MS,
STARTING_REPUTATION, STARTING_REPUTATION,
type Rng,
type SeedAirport, type SeedAirport,
} from './seed'; } from './seed';
@@ -44,6 +53,8 @@ describe('buildSeed', () => {
destIcao: null, destIcao: null,
lat: 50.0333, lat: 50.0333,
lon: 8.5706, 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
}); });
}); });
@@ -64,33 +75,51 @@ describe('buildSeed', () => {
expect(c.originIcao).not.toBe(c.destIcao); expect(c.originIcao).not.toBe(c.destIcao);
expect(c.status).toBe('open'); expect(c.status).toBe('open');
expect(c.planeId).toBeNull(); expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW); expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
} }
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
}); });
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); const seed = seedWith(42);
for (const c of seed.packages) { for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
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 the game-clock origin (scaled by any special status)', () => {
const seed = seedWith(42); const seed = seedWith(42);
for (const c of seed.packages) { for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); // v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000); // ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 72 * 3_600_000 * scale);
} }
}); });
it('seeds the starting account and sim state', () => { it('seeds the starting account and sim state', () => {
const seed = seedWith(42); const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION }); expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0. // ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW }); // the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
}); });
it('is fully deterministic for a fixed RNG and inputs', () => { it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -105,9 +134,12 @@ describe('buildSeed', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow(); expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
}); });
it('rewardForDistanceKm applies the documented formula', () => { it('rewardFor applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD); expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000)); 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 +158,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
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. // Same 6–72h game-time deadline window as the seed — scaled by any special status.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000); 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', () => { it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
@@ -154,7 +189,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1)); const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = []; const expected: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW }))); // ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
} }
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected); expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
@@ -172,7 +208,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
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', () => { it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -188,7 +226,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS[0]; // EDDF const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM 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', () => { it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -211,4 +251,189 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(a).toEqual(b); 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);
}
});
}); });
+131 -18
View File
@@ -1,5 +1,7 @@
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
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) // 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 // so it is fully deterministic and unit-testable: the RNG is injected, never
@@ -38,6 +40,11 @@ export interface SeedPlane {
destIcao: null; destIcao: null;
lat: number; lat: number;
lon: 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 { export interface SeedPackage {
@@ -47,6 +54,10 @@ export interface SeedPackage {
reward: number; reward: number;
cargoType: string; cargoType: string;
cargoCount: number; 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; deadlineSimTime: number;
status: 'open'; status: 'open';
planeId: null; planeId: null;
@@ -57,7 +68,7 @@ export interface SeedData {
plane: SeedPlane; plane: SeedPlane;
packages: SeedPackage[]; packages: SeedPackage[];
account: { cash: number; reputation: number }; 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) --- // --- Tunables (documented so future revenue/cost work can build on them) ---
@@ -65,6 +76,10 @@ export interface SeedData {
export const STARTING_CASH = 20_000; export const STARTING_CASH = 20_000;
export const STARTING_REPUTATION = 10; export const STARTING_REPUTATION = 10;
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
// The starter plane grounds at a large central-European hub. If the ICAO is // The starter plane grounds at a large central-European hub. If the ICAO is
// ever missing from the dataset we fall back to the first airport rather than // ever missing from the dataset we fall back to the first airport rather than
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.) // failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
@@ -77,10 +92,32 @@ export const PACKAGE_COUNT_MAX = 12; // inclusive
// count after expiries, so the job board never runs dry. // count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10; export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a // Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km. // 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 BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5; 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. // Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6; const DEADLINE_MIN_HOURS = 6;
@@ -88,19 +125,38 @@ const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const; const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number { // ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
return Math.round(BASE_REWARD + REWARD_PER_KM * km); // 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 // 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 // market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
// distinct airports from the dataset, distance-based reward, cargo flavor, // distinct airports from the dataset, distance+weight-based reward, cargo flavor,
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by // weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// the e2e test hook to force a near-term expiry). The RNG call order per package // game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// is fixed: origin, dest, cargoType, cargoCount, [deadline window]. // 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 + // 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 // the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before. // 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: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -109,6 +165,9 @@ export function generatePackage(input: {
deadlineOffsetMs?: number; deadlineOffsetMs?: number;
originIcao?: string; originIcao?: string;
destIcao?: string; destIcao?: string;
weightKg?: number;
special?: string | null;
reputation?: number;
}): SeedPackage { }): SeedPackage {
const { airports, rng, nowMs } = input; const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports'); if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -130,6 +189,27 @@ export function generatePackage(input: {
} else { } else {
di = Math.floor(rng() * airports.length); di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % 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 if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
const origin = airports[oi]; const origin = airports[oi];
@@ -140,16 +220,31 @@ export function generatePackage(input: {
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets. // Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount = const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50); cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
const deadlineMs = // Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; // 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 { return {
id: input.id, id: input.id,
originIcao: origin.icao, originIcao: origin.icao,
destIcao: dest.icao, destIcao: dest.icao,
reward: rewardForDistanceKm(km), reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType, cargoType,
cargoCount, cargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs), deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -157,6 +252,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 { function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0]; return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
} }
@@ -182,22 +290,27 @@ export function buildSeed(input: {
destIcao: null, destIcao: null,
lat: hub.lat, lat: hub.lat,
lon: hub.lon, 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)); const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
// Stable index-based ids for the seed set; runtime-generated entities must use // Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these. // random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate.
const packages: SeedPackage[] = []; const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs })); packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
} }
return { return {
plane, plane,
packages, packages,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances // ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
// by ~0 instead of treating "time since install" as elapsed play time. // is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs }, // install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
// stability — no game code reads them anymore.
simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
}; };
} }
+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)];
}
+62 -8
View File
@@ -1,16 +1,18 @@
import { loadAirportsGeoJson } from '@/lib/airports'; 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 { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state). // Read-only game-state snapshot for client polling (GET /api/state).
export interface PlaneState { export interface PlaneState {
id: string; id: string;
model: 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; fuel: number;
maxFuel: number; maxFuel: number;
locationIcao: string; locationIcao: string;
@@ -24,6 +26,27 @@ export interface PlaneState {
departureSimTime: number | null; departureSimTime: number | null;
lat: number; lat: number;
lon: 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 { export interface PackageState {
@@ -35,19 +58,28 @@ export interface PackageState {
reward: number; reward: number;
cargoType: string; cargoType: string;
cargoCount: number; 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; 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; planeId: string | null;
createdSimTime: number; createdSimTime: number;
} }
export interface StateSnapshot { export interface StateSnapshot {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; // ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
// only the clock itself is reported here.
simState: { clockMs: number };
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so // Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint. // the client can compute buy/sell previews without a separate endpoint.
catalog: CatalogEntry[]; catalog: CatalogEntry[];
fuelPricePerL: number; fuelPricePerL: number;
sellFraction: number; sellFraction: number;
@@ -72,6 +104,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao; const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); 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 // Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock. // departure values — live position and fuel are pure functions of the clock.
@@ -84,12 +122,22 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
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 { return {
account: { cash: accountRow.cash, reputation: accountRow.reputation }, account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused }, simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(), catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L, fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION, sellFraction: SELL_FRACTION,
@@ -108,6 +156,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
departureSimTime: p.departureSimTime, departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat, lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon, 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) => ({ packages: packageRows.map((p) => ({
@@ -119,6 +170,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
reward: p.reward, reward: p.reward,
cargoType: p.cargoType, cargoType: p.cargoType,
cargoCount: p.cargoCount, cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime, deadlineSimTime: p.deadlineSimTime,
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('haversineKm', () => {
it('returns 0 for identical points', () => { it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371; 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. // Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
+239 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { StateSnapshot } from './game/state'; 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'; import { haversineKm } from './geo';
const AIRPORTS = new Map<string, AirportCoord>([ const AIRPORTS = new Map<string, AirportCoord>([
@@ -8,16 +8,34 @@ const AIRPORTS = new Map<string, AirportCoord>([
['EDDV', { lat: 52.46, lon: 9.68 }], ['EDDV', { lat: 52.46, lon: 9.68 }],
['EHAM', { lat: 52.31, lon: 4.76 }], ['EHAM', { lat: 52.31, lon: 4.76 }],
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test ['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
]); ]);
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
const modDiff = (a: number, b: number): number => {
const d = Math.abs(a - b) % 360;
return Math.min(d, 360 - d);
};
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
// keeps MapLibre from drawing a chord across the map.
function expectContinuousLons(coords: [number, number][]): void {
for (let i = 1; i < coords.length; i++) {
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
}
}
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot { function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
return { return {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false }, simState: { clockMs: 0 },
planes: [], planes: [],
packages: [], packages: [],
catalog: [ 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, fuelPricePerL: 0.8,
sellFraction: 0.5, sellFraction: 0.5,
@@ -34,8 +52,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 }, { 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 }, { 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 +79,7 @@ describe('planesGeoJson', () => {
snapshot({ snapshot({
catalog: [], catalog: [],
planes: [ planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 }, { 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 +90,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 }, { 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 +107,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 }, { 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, AIRPORTS,
@@ -101,7 +119,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 }, { 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, AIRPORTS,
@@ -118,7 +136,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 }, { 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, AIRPORTS,
@@ -130,8 +148,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 }, { 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 }, { 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, AIRPORTS,
@@ -140,6 +158,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)', () => { describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
it('samples >2 points with the endpoints exactly at the airport coords', () => { it('samples >2 points with the endpoints exactly at the airport coords', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!); const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
@@ -169,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
}); });
}); });
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
const arc = greatCircleArc(from, to);
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
let sum = 0;
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
});
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
const arc = greatCircleArc(to, from);
expectContinuousLons(arc);
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
});
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
expectContinuousLons(arc);
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
});
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
const coords = fc.features[0]!.geometry.coordinates;
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
const last = coords[coords.length - 1]!;
const nzch = AIRPORTS.get('NZCH')!;
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
});
});
+118 -5
View File
@@ -1,4 +1,4 @@
import type { StateSnapshot } from './game/state'; import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format'; import { formatFuel } from './format';
import { positionAt } from './sim/movement'; import { positionAt } from './sim/movement';
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
export interface PlaneFeatureProps { export interface PlaneFeatureProps {
id: string; id: string;
modelLabel: 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" fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded routeText: string; // "Origin → Destination" while flying, '' when grounded
flying: boolean; flying: boolean;
@@ -73,16 +74,91 @@ export interface AirportCoord {
// curved path the plane actually flies (same slerp as lib/sim/movement.ts). // curved path the plane actually flies (same slerp as lib/sim/movement.ts).
export const ROUTE_ARC_SEGMENTS = 24; export const ROUTE_ARC_SEGMENTS = 24;
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
function unwrapLons(coords: [number, number][]): [number, number][] {
let prev = coords[0]![0];
for (let i = 1; i < coords.length; i++) {
let lon = coords[i]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
coords[i]![0] = lon;
prev = lon;
}
return coords;
}
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at // Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport // t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
// coords (positionAt returns them verbatim at t=0 and t=1). // ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] { export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
const coords: [number, number][] = []; const coords: [number, number][] = [];
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) { for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
coords.push([pos.lon, pos.lat]); coords.push([pos.lon, pos.lat]);
} }
return coords; return unwrapLons(coords);
}
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
// 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. // One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
@@ -111,3 +187,40 @@ export function routesGeoJson(
} }
return { type: 'FeatureCollection', features }; 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
}
// ISS-120: each leg's arc is unwrapped relative to its OWN start airport, so at a joint the
// sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) — re-unify.
unwrapLons(coords);
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };
}
+41 -46
View File
@@ -3,16 +3,18 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => { it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/refuel', '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/sim', '/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
@@ -92,18 +94,17 @@ describe('OpenAPI spec', () => {
const catalog = openApiSpec.components.schemas.CatalogEntry.properties; const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual([ expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm', 'burnPerKm',
'capacity',
'emptyWeightKg', // v0.16.0 weighted burn model input 'emptyWeightKg', // v0.16.0 weighted burn model input
'label', 'label',
'maxFuel', 'maxFuel',
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
'model', 'model',
'price', 'price',
'speedKmh', '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[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.model.type).toBe('string');
expect(catalog.label.type).toBe('string'); expect(catalog.label.type).toBe('string');
@@ -113,6 +114,7 @@ describe('OpenAPI spec', () => {
'destIcao', 'destIcao',
'destName', 'destName',
'fuel', 'fuel',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'id', 'id',
'lat', 'lat',
'locationIcao', 'locationIcao',
@@ -120,6 +122,8 @@ describe('OpenAPI spec', () => {
'lon', 'lon',
'maxFuel', 'maxFuel',
'model', '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', 'status',
]); ]);
@@ -131,45 +135,26 @@ describe('OpenAPI spec', () => {
'deadlineSimTime', 'deadlineSimTime',
'destIcao', 'destIcao',
'destName', 'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'id', 'id',
'originIcao', 'originIcao',
'originName', 'originName',
'planeId', 'planeId',
'reward', 'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status', 'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]); ]);
// ISS-117 (v0.52.0): the snapshot carries only the game clock — time controls are gone.
const sim = openApiSpec.components.schemas.SimState.properties; const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']); expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; 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', () => { it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
const post = openApiSpec.paths['/api/sim'].post;
expect(post.operationId).toBe('setSimControls');
const requestSchema = post.requestBody.content['application/json'].schema;
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
const ok = post.responses[200].content['application/json'].schema;
expect(ok.$ref).toBe('#/components/schemas/SimState');
const bad = post.responses[400].content['application/json'].schema;
expect(bad.$ref).toBe('#/components/schemas/Error');
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
expect(controls.timeScale.type).toBe('number');
expect(controls.timeScale.minimum).toBe(1);
expect(controls.timeScale.maximum).toBe(1000);
expect(controls.paused.type).toBe('boolean');
const error = openApiSpec.components.schemas.Error.properties;
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON. // The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) => const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as { ((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
@@ -183,9 +168,11 @@ describe('OpenAPI spec', () => {
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/planes/{planeId}/fly', '/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/buy-plane',
'/api/market/sell-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 } }> }; const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot'); expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
@@ -196,18 +183,25 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['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}/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/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '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; // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/LoadPackageRequest', '#/components/schemas/LoadPackageRequest',
); );
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/FlyPlaneRequest', '#/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( expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/BuyPlaneRequest', '#/components/schemas/BuyPlaneRequest',
); );
@@ -215,16 +209,16 @@ describe('OpenAPI spec', () => {
'#/components/schemas/SellPlaneRequest', '#/components/schemas/SellPlaneRequest',
); );
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined(); expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody; expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
// Path parameters are declared on the parameterized routes. // Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name); const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']); expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']); expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']); 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. // Request schema shapes.
const load = openApiSpec.components.schemas.LoadPackageRequest; const load = openApiSpec.components.schemas.LoadPackageRequest;
@@ -233,17 +227,18 @@ describe('OpenAPI spec', () => {
const fly = openApiSpec.components.schemas.FlyPlaneRequest; const fly = openApiSpec.components.schemas.FlyPlaneRequest;
expect(fly.required).toEqual(['destIcao']); expect(fly.required).toEqual(['destIcao']);
expect(fly.properties.destIcao.type).toBe('string'); 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; const buy = openApiSpec.components.schemas.BuyPlaneRequest;
expect(buy.required).toEqual(['model']); expect(buy.required).toEqual(['model']);
expect(buy.properties.model.type).toBe('string'); expect(buy.properties.model.type).toBe('string');
const sell = openApiSpec.components.schemas.SellPlaneRequest; const sell = openApiSpec.components.schemas.SellPlaneRequest;
expect(sell.required).toEqual(['planeId']); expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string'); 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');
}); });
}); });
+164 -105
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'Flight Ops API', title: 'Flight Ops API',
description: description:
'JSON API for Flight Ops — 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. // Sourced from package.json so it stays in sync with every version bump.
version: pkg.version, version: pkg.version,
}, },
@@ -17,13 +17,13 @@ export const openApiSpec = {
paths: { paths: {
'/api/airports': { '/api/airports': {
get: { get: {
summary: 'List EU airports', summary: 'List world airports',
description: 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', operationId: 'listAirports',
responses: { responses: {
200: { 200: {
description: 'GeoJSON FeatureCollection of EU airports', description: 'GeoJSON FeatureCollection of world airports',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/AirportFeatureCollection' }, schema: { $ref: '#/components/schemas/AirportFeatureCollection' },
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: { get: {
summary: 'Game state snapshot', summary: 'Game state snapshot',
description: 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. The world clock advances on a ~1 s background tick at a fixed rate (v0.52.0, ISS-117: no player-facing time controls), clamped per tick; while no plane is flying or in repair the clock stays frozen. Each call ticks first, so expiry/delivery/spawn are always applied before the snapshot is returned.',
operationId: 'getState', operationId: 'getState',
responses: { responses: {
200: { 200: {
@@ -51,40 +51,6 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/sim': {
post: {
summary: 'Set sim time controls',
description:
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.',
operationId: 'setSimControls',
requestBody: {
required: false,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimControlsRequest' },
},
},
},
responses: {
200: {
description: 'Updated sim state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimState' },
},
},
},
400: {
description: 'Invalid body (not an object, unknown field, or wrong types)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/version': { '/api/version': {
get: { get: {
summary: 'App version', summary: 'App version',
@@ -106,7 +72,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Load a package onto a plane', summary: 'Load a package onto a plane',
description: 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', operationId: 'loadPackage',
parameters: [ parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'id', in: 'path', required: true, schema: { type: 'string' } },
@@ -165,11 +131,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': { '/api/planes/{planeId}/fly': {
post: { post: {
summary: 'Fly a plane to an airport', summary: 'Fly a plane to an airport',
description: 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', operationId: 'flyPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -198,7 +189,80 @@ export const openApiSpec = {
}, },
409: { 409: {
description: 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' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
}, },
@@ -208,7 +272,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Buy a plane from the market', summary: 'Buy a plane from the market',
description: 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', operationId: 'buyPlane',
requestBody: { requestBody: {
required: true, required: true,
@@ -268,39 +332,26 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/planes/{planeId}/refuel': { '/api/planes/{planeId}/repair': {
post: { post: {
summary: 'Refuel a grounded plane', summary: 'Repair a grounded plane',
description: 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.', '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: 'refuelPlane', operationId: 'repairPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
], ],
requestBody: {
required: false,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/RefuelRequest' },
},
},
},
responses: { responses: {
200: { 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' } } }, 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: { 404: {
description: 'Unknown plane id (or unknown package id in forFlight mode)', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
409: { 409: {
description: description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
'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',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
}, },
@@ -378,7 +429,7 @@ export const openApiSpec = {
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.', description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' }, 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.' }, sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
}, },
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
@@ -388,49 +439,34 @@ export const openApiSpec = {
properties: { properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' }, 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").' }, 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.' }, 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.' }, 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.' }, 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: { AccountState: {
type: 'object', type: 'object',
properties: { properties: {
cash: { type: 'number' }, 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'], required: ['cash', 'reputation'],
}, },
SimState: { SimState: {
type: 'object', type: 'object',
properties: { properties: {
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time). The world runs at a fixed rate with no player-facing controls (v0.52.0, ISS-117).' },
timeScale: { type: 'number', minimum: 1, maximum: 1000 },
paused: { type: 'boolean' },
},
required: ['clockMs', 'timeScale', 'paused'],
},
SimControlsRequest: {
type: 'object',
description: 'Partial update — omit a field to leave it unchanged.',
properties: {
timeScale: {
type: 'number',
minimum: 1,
maximum: 1000,
description: 'Game-time multiplier; values outside [1, 1000] are clamped. The UI offers the ladder 1/2/5/10/25/50/100/250/500/1000.',
},
paused: { type: 'boolean' },
}, },
required: ['clockMs'],
}, },
LoadPackageRequest: { LoadPackageRequest: {
type: 'object', type: 'object',
properties: { 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'], required: ['planeId'],
}, },
@@ -441,6 +477,20 @@ export const openApiSpec = {
}, },
required: ['destIcao'], 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: { BuyPlaneRequest: {
type: 'object', type: 'object',
properties: { properties: {
@@ -455,22 +505,6 @@ export const openApiSpec = {
}, },
required: ['planeId'], 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: { Error: {
type: 'object', type: 'object',
properties: { properties: {
@@ -483,7 +517,7 @@ export const openApiSpec = {
properties: { properties: {
id: { type: 'string' }, id: { type: 'string' },
model: { 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' }, fuel: { type: 'number' },
maxFuel: { type: 'number' }, maxFuel: { type: 'number' },
locationIcao: { type: 'string' }, locationIcao: { type: 'string' },
@@ -502,6 +536,22 @@ export const openApiSpec = {
}, },
lat: { type: 'number' }, lat: { type: 'number' },
lon: { 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: [ required: [
'id', 'id',
@@ -516,6 +566,9 @@ export const openApiSpec = {
'departureSimTime', 'departureSimTime',
'lat', 'lat',
'lon', '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: { PackageState: {
@@ -529,10 +582,14 @@ export const openApiSpec = {
reward: { type: 'number' }, reward: { type: 'number' },
cargoType: { type: 'string' }, cargoType: { type: 'string' },
cargoCount: { type: 'integer' }, 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)' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick; // 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free. // loaded expired packages keep their slot, deliver no reward, abandon free.
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'] }, planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' }, createdSimTime: { type: 'integer' },
}, },
@@ -545,6 +602,8 @@ export const openApiSpec = {
'reward', 'reward',
'cargoType', 'cargoType',
'cargoCount', 'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'deadlineSimTime', 'deadlineSimTime',
'status', 'status',
'planeId', 'planeId',
+411 -61
View File
@@ -6,27 +6,37 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { weatherFor } from '@/lib/weather';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; 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 { arrivalTimeFor, fuelAtWeighted } from './movement'; import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
clampTimeScale,
deliverPackages, deliverPackages,
expirePackages, expirePackages,
finishRepairs,
MAX_TICK_ELAPSED_MS, MAX_TICK_ELAPSED_MS,
MAX_TIME_SCALE,
MIN_TIME_SCALE,
reputationGainFor, reputationGainFor,
shouldAdvanceTime,
spawnCoveragePackages, spawnCoveragePackages,
spawnOpenPackages, spawnOpenPackages,
tickOnce, tickOnce,
WORLD_TIME_SCALE,
} from './engine'; } from './engine';
// Engine tests run against throwaway DB files (openGameDb) with fake timers so // Engine tests run against throwaway DB files (openGameDb) with fake timers so
// Date.now() — the only wall-clock input to a tick — is fully controlled. The // Date.now() — the only wall-clock input to a tick — is fully controlled. The
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered // public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
// by app/api/sim/route.test.ts and the e2e suite. // (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
.where(eq(schema.planes.id, 'plane-starter'));
}
let dir: string; let dir: string;
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
@@ -50,43 +60,46 @@ async function freshDb(name: string) {
describe('tickOnce — clock advance', () => { describe('tickOnce — clock advance', () => {
it('advances the clock by elapsed real ms × timeScale', async () => { it('advances the clock by elapsed real ms × timeScale', async () => {
const inst = await freshDb('scale.db'); const inst = await freshDb('scale.db');
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117)
vi.setSystemTime(T0 + 1_000); // one real second later vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 }); await inst.db.update(schema.simState).set({ timeScale: 5 });
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5 expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 5_000); // 1 s × 5
expect(sim.lastRealTickMs).toBe(T0 + 1_000); expect(sim.lastRealTickMs).toBe(T0 + 1_000);
}); });
it('freezes the clock while paused but keeps tracking real time', async () => { it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
const inst = await freshDb('pause.db'); const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
vi.setSystemTime(T0 + 3_600_000); // an hour of real pause vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
let sim = await tickOnce(inst); let sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0); // frozen — no advance at all expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
// Unpause: resumes from where it stopped, with NO catch-up for the pause. // A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
await inst.db.update(schema.simState).set({ paused: false }); await inst.db.update(schema.simState).set({ timeScale: 60 });
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_500); vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst); sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60 expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
}); });
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => { it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
const inst = await freshDb('clamp.db'); const inst = await freshDb('clamp.db');
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
const sim = await tickOnce(inst); // timeScale is the seeded 1 const sim = await tickOnce(inst); // timeScale is the seeded WORLD_TIME_SCALE
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * WORLD_TIME_SCALE); // exactly one clamped tick, not a fast-forward
vi.setSystemTime(T0); // reset for later tests in this file vi.setSystemTime(T0); // reset for later tests in this file
}); });
it('does not advance when two ticks land in the same millisecond', async () => { it('does not advance when two ticks land in the same millisecond', async () => {
const inst = await freshDb('same-ms.db'); const inst = await freshDb('same-ms.db');
await makeWorldActive(inst);
vi.setSystemTime(T0 + 1_000); vi.setSystemTime(T0 + 1_000);
const a = await tickOnce(inst); const a = await tickOnce(inst);
const b = await tickOnce(inst); // no real time passed since `a` const b = await tickOnce(inst); // no real time passed since `a`
@@ -97,14 +110,17 @@ describe('tickOnce — clock advance', () => {
describe('expirePackages', () => { describe('expirePackages', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db'); const inst = await freshDb('expiry.db');
const clock = T0 + 100_000; // Game-clock anchor just after the seed moment — well inside every seeded package's deadline
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
const clock = STARTING_CLOCK_MS + 100_000;
await inst.db.insert(schema.packages).values([ await inst.db.insert(schema.packages).values([
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 }, { id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 }, { id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
]); ]);
const n = await expirePackages(inst, clock); const n = await expirePackages(inst, clock);
@@ -116,15 +132,16 @@ describe('expirePackages', () => {
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot) expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
}); });
it('a deadline exactly at the clock counts as expired', async () => { it('a deadline exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-edge.db'); const inst = await freshDb('expiry-edge.db');
await inst.db.insert(schema.packages).values({ await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0, deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
}); });
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
}); });
}); });
@@ -163,7 +180,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db'); const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO, // Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -182,8 +199,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO); expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat); expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon); expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form). // Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6); // (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.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -228,6 +248,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
); // rep per delivered package ); // 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 () => { it('leaves planes without route info alone', async () => {
const inst = await freshDb('deliver-noroute.db'); const inst = await freshDb('deliver-noroute.db');
// Flying plane whose dest_icao is missing — defensive skip, no crash. // Flying plane whose dest_icao is missing — defensive skip, no crash.
@@ -235,6 +275,335 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ status: 'flying', departureSimTime: T0 }); .set({ status: 'flying', departureSimTime: T0 });
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0); 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');
// The repairing plane keeps the world advancing (ISS-117); scale pinned to 1 for clean math.
await inst.db.update(schema.simState).set({ timeScale: 1 });
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + 1_500 })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock STARTING_CLOCK_MS+2000 ≥ gate
await tickOnce(inst);
const plane = (await inst.db.select().from(schema.planes))[0];
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('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'repairing' }])).toEqual({ advance: true, flying: 0, repairing: 1 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
});
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
vi.setSystemTime(T0 + 3_600_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
});
it('a repairing plane keeps the world advancing', async () => {
const inst = await freshDb('idle-repair.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
});
}); });
describe('spawnOpenPackages', () => { describe('spawnOpenPackages', () => {
@@ -262,9 +631,10 @@ describe('spawnOpenPackages', () => {
expect(icaoSet.has(p.destIcao)).toBe(true); expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao); expect(p.originIcao).not.toBe(p.destIcao);
expect(p.planeId).toBeNull(); expect(p.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time. // Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1); const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000); 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. // Already at target → nothing more to spawn.
@@ -367,7 +737,13 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => { it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db'); const inst = await freshDb('coverage-tick.db');
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1 // A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here.
await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
});
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
await tickOnce(inst); await tickOnce(inst);
@@ -378,32 +754,6 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
}); });
}); });
describe('clampTimeScale — v0.14.0 ladder bounds [1, 1000]', () => {
it('clamps below-minimum values to MIN_TIME_SCALE', () => {
expect(clampTimeScale(0)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(-5)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(0.1)).toBe(MIN_TIME_SCALE);
});
it('clamps above-maximum values to MAX_TIME_SCALE', () => {
expect(clampTimeScale(1001)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(2000)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIME_SCALE);
});
it('passes in-range values through unclamped — ladder and off-ladder alike', () => {
for (const s of [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]) {
expect(clampTimeScale(s)).toBe(s); // the full v0.14.0 slider ladder
}
expect(clampTimeScale(37)).toBe(37); // off-ladder but in range (e.g. a legacy value)
});
it('keeps the endpoints exact', () => {
expect(clampTimeScale(MIN_TIME_SCALE)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(MAX_TIME_SCALE)).toBe(MAX_TIME_SCALE);
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the // Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures). // engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number { function travelMs(km: number, speedKmh: number): number {
+224 -57
View File
@@ -3,31 +3,35 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog'; 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 { import {
generatePackage, generatePackage,
MARKET_TARGET_OPEN_PACKAGES, MARKET_TARGET_OPEN_PACKAGES,
mulberry32, mulberry32,
pickDestWithinCap,
type Rng, type Rng,
type SeedAirport, type SeedAirport,
} from '@/lib/game/seed'; } 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 // Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by // tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
// (real ms since last tick) × timeScale, clamped per tick so long absences never // same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
// fast-forward the game. // per tick so long absences never fast-forward the game; while no plane is flying or in repair the
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time.
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — // Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
// comfortably above a normal client poll interval (~1 s), so active play advances // both the client poll interval (~1 s) and the background-tick interval, so active play advances
// ~real time × scale; small enough that one stale read after an absence moves game // ~real time × scale; small enough that one stale read after an absence moves game time by at most
// time by at most 2 s × scale (≈ 33 min even at max scale 1000). The sim therefore // 2 s × scale.
// only meaningfully progresses while actively polled.
export const MAX_TICK_ELAPSED_MS = 2_000; export const MAX_TICK_ELAPSED_MS = 2_000;
export const MIN_TIME_SCALE = 1; // ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home.
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0) export { WORLD_TIME_SCALE } from '@/lib/worldClock';
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded // Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a // to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
@@ -39,11 +43,6 @@ export function reputationGainFor(km: number): number {
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
} }
export interface SimControlsInput {
timeScale?: number;
paused?: boolean;
}
// Serialize ticks across concurrent requests (Next.js route handlers run in the // Serialize ticks across concurrent requests (Next.js route handlers run in the
// same process): each tick re-reads sim_state after the previous one wrote it, so // same process): each tick re-reads sim_state after the previous one wrote it, so
// no advance is ever applied twice. // no advance is ever applied twice.
@@ -59,21 +58,39 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) --- // --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock
// advances only while something is happening: at least one plane flying OR in repair. A fully
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0;
let repairing = 0;
for (const p of planes) {
if (p.status === 'flying') flying++;
else if (p.status === 'repairing') repairing++;
}
return { advance: flying > 0 || repairing > 0, flying, repairing };
}
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> { export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const rows = await inst.db.select().from(schema.simState); const rows = await inst.db.select().from(schema.simState);
const sim = rows[0]; const sim = rows[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now(); const nowMs = Date.now();
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
const planeRows = await inst.db.select().from(schema.planes);
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
// While paused the clock is frozen; lastRealTickMs still tracks real time so const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
// unpausing resumes cleanly with no catch-up jump for the pause duration.
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
const clockMs = Math.round(sim.clockMs + advanceMs); const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) { if (advanceMs > 0) {
await expirePackages(inst, clockMs); await expirePackages(inst, clockMs);
await deliverPackages(inst, clockMs); await deliverPackages(inst, clockMs);
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
await finishRepairs(inst, clockMs);
await spawnOpenPackages(inst, clockMs); await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned // AFTER delivery on purpose: a plane that just landed gets work spawned
// at its new location within the same tick. // at its new location within the same tick.
@@ -104,13 +121,51 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n; return n;
} }
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
// 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): // 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 // for every package bound to the plane and destined for its dest_icao — 'loaded'
// package bound to it and destined for that airport — 'loaded' ones still inside // ones still inside their deadline are delivered (cash += reward + reputation
// their deadline are delivered (cash += reward + reputation gain per package), // gain per package), 'expired' ones just unbind (no reward). Packages destined
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded // elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
// on the plane for a later leg. Idempotent — a finalized plane leaves the // auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
// 'flying' state, so it can never be awarded twice. Returns planes landed. // 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.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> { export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([ const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes), inst.db.select().from(schema.planes),
@@ -131,20 +186,23 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn. // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
await inst.db.update(schema.planes) // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
.set({ const w = weatherFor(p.locationIcao);
status: 'available', const landedFuel = fuelAtWeighted(
locationIcao: to.icao, p.fuel,
lat: to.lat, type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
lon: to.lon, type.emptyWeightKg,
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1), type.maxFuel,
departureSimTime: null, distanceKm,
destIcao: null, 1,
}) );
.where(eq(schema.planes.id, p.id));
// 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); const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
for (const pk of onBoard) { for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) { if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
@@ -152,15 +210,100 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// Fresh account read per award so multiple deliveries in one tick all count. // Fresh account read per award so multiple deliveries in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0]; const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing'); 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({ await inst.db.update(schema.account).set({
cash: fresh.cash + pk.reward, 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') { } else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot. // 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)); 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++; n++;
} }
return n; return n;
@@ -188,9 +331,14 @@ export async function spawnOpenPackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): 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++) { 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; return needed;
} }
@@ -232,12 +380,27 @@ export async function spawnCoveragePackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
let n = 0; let n = 0;
for (const icao of missing) { 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( 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++; n++;
} }
@@ -250,6 +413,17 @@ export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => tickOnce(await getGameDb())); return withLock(async () => tickOnce(await getGameDb()));
} }
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
// tick path as reads/actions. Started once at server boot from instrumentation.ts (Next ≥15); the
// guard makes double-starts harmless (dev HMR reloads).
let backgroundTimer: ReturnType<typeof setInterval> | null = null;
export function startBackgroundTick(intervalMs = 1_000): void {
if (backgroundTimer !== null) return;
backgroundTimer = setInterval(() => {
tickNow().catch((err) => console.error('flight: background tick failed', err));
}, intervalMs);
}
// Run a game action atomically with respect to ticks (v0.8.0): the clock is // Run a game action atomically with respect to ticks (v0.8.0): the clock is
// advanced FIRST so validation and mutation see current game time, then fn runs — // advanced FIRST so validation and mutation see current game time, then fn runs —
// all under the same promise lock as tickNow(), so no concurrent request can // all under the same promise lock as tickNow(), so no concurrent request can
@@ -263,24 +437,17 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
}); });
} }
// Tick first so the returned state reflects current time, then apply the new // Test-hook backing (FLIGHT_TEST_HOOKS=1 only — see app/api/test/set-timescale/route.ts): set the
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in // effective time scale under the same lock as ticks so a background tick can never interleave
// the route handler (400 on non-number / non-boolean). // between read and write. Input validation happens in the route; this trusts its number.
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> { export function setTestTimeScale(scale: number): Promise<schema.SimStateRow> {
return withLock(async () => { return withLock(async () => {
const inst = await getGameDb(); const inst = await getGameDb();
await tickOnce(inst); await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {}; await inst.db.update(schema.simState).set({ timeScale: scale });
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
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); await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0]; const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing'); if (!updated) throw new Error('sim_state row missing');
return updated; return updated;
}); });
} }
export function clampTimeScale(v: number): number {
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
}
+91
View File
@@ -5,10 +5,14 @@ import {
burnRateConstant, burnRateConstant,
FUEL_DENSITY_KG_PER_L, FUEL_DENSITY_KG_PER_L,
fuelAtWeighted, fuelAtWeighted,
guaranteedRangeKm,
MAX_WIND_BURN_FACTOR,
maxRangeKm,
positionAt, positionAt,
progressAt, progressAt,
requiredFuelFor, requiredFuelFor,
travelDurationMs, travelDurationMs,
windBurnFactor,
} from './movement'; } from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of // Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -164,3 +168,90 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(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();
});
});
describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => {
it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => {
// Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy.
expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90));
});
it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => {
const r = guaranteedRangeKm(BURN, WE, TANK);
expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12);
expect(r).toBeGreaterThan(3696 * 0.95);
expect(r).toBeLessThan(3696 * 1.05);
});
it('zero fuel → zero range; rejects a non-positive empty weight', () => {
expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0);
expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow();
});
});
+32
View File
@@ -118,3 +118,35 @@ export function requiredFuelFor(
const rho = FUEL_DENSITY_KG_PER_L; const rho = FUEL_DENSITY_KG_PER_L;
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1); 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));
}
// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of
// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150.
export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150;
// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case
// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor
// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what
// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard
// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn).
export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel);
}
-81
View File
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest';
import { MAX_SCALE, MIN_SCALE, TIME_SCALES, scaleToSlider, sliderToScale } from './timeScale';
const STEPS = TIME_SCALES.length - 1; // 9 intervals across the 10 ladder positions (v0.14.0)
describe('sliderToScale', () => {
it('is exact at the endpoints (0 → 1×, 1 → 1000×)', () => {
expect(sliderToScale(0)).toBe(MIN_SCALE);
expect(sliderToScale(1)).toBe(MAX_SCALE);
});
it('maps each of the 10 positions to the ladder', () => {
const expected = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000];
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(sliderToScale(i / STEPS)).toBe(expected[i]);
}
});
it('is monotonic non-decreasing across the whole range', () => {
let prev = sliderToScale(0);
for (let i = 1; i <= 2000; i++) {
const s = sliderToScale(i / 2000);
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
});
it('only ever returns a ladder value', () => {
for (let i = 0; i <= 2000; i++) {
expect(TIME_SCALES).toContain(sliderToScale(i / 2000));
}
});
it('clamps out-of-range and non-finite input', () => {
expect(sliderToScale(-0.5)).toBe(MIN_SCALE);
expect(sliderToScale(1.5)).toBe(MAX_SCALE);
expect(sliderToScale(NaN)).toBe(MIN_SCALE);
});
});
describe('scaleToSlider', () => {
it('is exact at the endpoints (1× → 0, 1000× → 1)', () => {
expect(scaleToSlider(1)).toBe(0);
expect(scaleToSlider(1000)).toBe(1);
});
it('places each ladder value on its own position', () => {
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(scaleToSlider(TIME_SCALES[i])).toBeCloseTo(i / STEPS, 12);
}
});
it('stays within [0, 1] for in-range scales', () => {
for (let s = MIN_SCALE; s <= MAX_SCALE; s++) {
const t = scaleToSlider(s);
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
}
});
it('clamps out-of-range and non-finite input', () => {
expect(scaleToSlider(0)).toBe(0);
expect(scaleToSlider(-3)).toBe(0);
expect(scaleToSlider(1001)).toBe(1); // above the ladder top → clamped to 1000× → t=1
expect(scaleToSlider(NaN)).toBe(0);
});
});
describe('round-trip', () => {
it('sliderToScale(scaleToSlider(s)) === s for every ladder value', () => {
for (const s of TIME_SCALES) {
expect(sliderToScale(scaleToSlider(s))).toBe(s);
}
});
it('maps off-ladder scales to the nearest ladder step by log distance', () => {
expect(sliderToScale(scaleToSlider(3))).toBe(2); // ln 3 ≈ 1.10 → closer to 2 (ln ≈ 0.69) than 5 (ln ≈ 1.61)
expect(sliderToScale(scaleToSlider(60))).toBe(50); // legacy max from the v0.9.x era sits between 50 and 100, nearer 50
expect(sliderToScale(scaleToSlider(200))).toBe(250); // ln 200 ≈ 5.30 → closer to 250 (ln ≈ 5.52) than 100 (ln ≈ 4.61)
});
});
-33
View File
@@ -1,33 +0,0 @@
// Pure mapping helpers for the time-scale slider (v0.9.2, values reworked in v0.14.0) — no
// game logic, unit-testable in node. The slider has 10 discrete positions on a mixed
// ×2/×2.5 ladder: 1× → 2× → 5× → 10× → 25× → 50× → 100× → 250× → 500× → 1000×, so each
// step roughly doubles the speed (matching POST /api/sim's clamp range [1, 1000]).
export const TIME_SCALES = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000] as const;
export const MIN_SCALE: number = TIME_SCALES[0];
export const MAX_SCALE: number = TIME_SCALES[TIME_SCALES.length - 1];
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
// t → scale: the position maps to the nearest ladder step. Exact at the endpoints:
// sliderToScale(0) === 1, sliderToScale(1) === 1000.
export function sliderToScale(t: number): number {
const tt = clamp(Number.isFinite(t) ? t : 0, 0, 1);
return TIME_SCALES[clamp(Math.round(tt * (TIME_SCALES.length - 1)), 0, TIME_SCALES.length - 1)];
}
// scale → t: the nearest ladder position by log distance. Exact at the endpoints:
// scaleToSlider(1) === 0, scaleToSlider(1000) === 1; round-trips every value in TIME_SCALES.
export function scaleToSlider(scale: number): number {
const s = clamp(Number.isFinite(scale) ? scale : MIN_SCALE, MIN_SCALE, MAX_SCALE);
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < TIME_SCALES.length; i++) {
const d = Math.abs(Math.log(s) - Math.log(TIME_SCALES[i]));
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best / (TIME_SCALES.length - 1);
}
+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 };
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { fetchState } from './useGameState';
const SNAPSHOT = { const SNAPSHOT = {
account: { cash: 20_000, reputation: 10 }, account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false }, simState: { clockMs: 1_750_000_000_000 },
planes: [], planes: [],
packages: [], packages: [],
}; };
+1 -1
View File
@@ -28,7 +28,7 @@ export async function fetchState(): Promise<StateSnapshot> {
(window as unknown as Record<string, unknown>).__flightLastSnapshot = { (window as unknown as Record<string, unknown>).__flightLastSnapshot = {
planeIds: snapshot.planes.map((p) => p.id), planeIds: snapshot.planes.map((p) => p.id),
cash: snapshot.account.cash, cash: snapshot.account.cash,
timeScale: snapshot.simState.timeScale, clockMs: snapshot.simState.clockMs,
at: Date.now(), at: Date.now(),
}; };
} }
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
import { WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
clockMs: 1_750_000_000_000,
at: 1_000_000,
advancing: true,
...over,
});
describe('advanceSimClock', () => {
it('advances by exactly elapsed real-time × WORLD_TIME_SCALE while running', () => {
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000 * WORLD_TIME_SCALE);
// The e2e scenario: ~2.6 s real at the fixed world rate.
expect(advanceSimClock(anchor(), 1_002_600)).toBe(1_750_000_000_000 + 2_600 * WORLD_TIME_SCALE);
});
it('returns the anchor value exactly at zero elapsed', () => {
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
});
it('is frozen while idle regardless of elapsed time', () => {
const a = anchor({ advancing: false });
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
});
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
expect(advanceSimClock(anchor(), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor({ advancing: false }), 999_999)).toBe(1_750_000_000_000);
});
it('mirrors the server tick model (tickOnce): advance = elapsed × WORLD_TIME_SCALE while actively polled', () => {
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
for (const gap of [1, 250, 999, 1_999]) {
expect(advanceSimClock(anchor(), 1_000_000 + gap)).toBe(1_750_000_000_000 + gap * WORLD_TIME_SCALE);
}
});
});
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
import { WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
// server's idle-pause instead of a player-controlled pause.
export interface SimClockAnchor {
// Server truth at anchor time — the snapshot's simState.clockMs.
clockMs: number;
// Date.now() when that snapshot was received.
at: number;
// Whether the world clock is advancing (any plane flying or in repair).
advancing: boolean;
}
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
// the anchor value instead of going backwards. Unit-testable in node.
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
if (!anchor.advancing) return anchor.clockMs;
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
}
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
const DISPLAY_TICK_MS = 100;
// Returns the display clockMs to render, or null until the first snapshot arrives.
export function useSimClock(snapshot: StateSnapshot | null): number | null {
const anchorRef = useRef<SimClockAnchor | null>(null);
const [displayMs, setDisplayMs] = useState<number | null>(null);
// Re-sync on every snapshot: 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;
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
// flying or in repair. One line on purpose — keep it in sync with that function.
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
}, [snapshot]);
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 };
}
+4
View File
@@ -0,0 +1,4 @@
// ISS-117 (v0.52.0): the world clock runs at a fixed rate — no player-facing time controls.
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
export const WORLD_TIME_SCALE = 24;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.16.0", "version": "0.53.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "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(', ')}`);