Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11fc6f9d5c | ||
|
|
8f32d5bf81 | ||
|
|
a8233829c3 | ||
|
|
bf6f1b3c95 | ||
|
|
9ce8660fd2 | ||
|
|
3dbac9c549 | ||
|
|
b76acbacac | ||
|
|
479b993c09 | ||
|
|
76f5b6f8ce | ||
|
|
1401a2f6eb | ||
|
|
0de0aaffd9 | ||
|
|
3ac7075eb0 | ||
|
|
320bdf3b72 | ||
|
|
dd7159c109 | ||
|
|
78032a2051 | ||
|
|
64da8e67bf | ||
|
|
b2d03846f5 | ||
|
|
68cfad434b | ||
|
|
5d92b5edaa | ||
|
|
4a6a8592e0 | ||
|
|
b8f321dfd5 | ||
|
|
336ed4ee7c | ||
|
|
929452ca33 | ||
|
|
8daca54840 | ||
|
|
0f5f3ae23e | ||
|
|
8ec485b93e | ||
|
|
ea740fc378 | ||
|
|
718e3a0177 | ||
|
|
5030fcd8b4 | ||
|
|
6d8e1ad35e | ||
|
|
0ac133f750 | ||
|
|
d3038b3c7f | ||
|
|
847e74b7b8 | ||
|
|
394d51d827 |
@@ -1,5 +1,99 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 }));
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -58,9 +60,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', 'weightKg', '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 () => {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { tickNow } from '@/lib/sim/engine';
|
|||||||
// 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
|
// - 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.
|
// 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) {
|
||||||
@@ -40,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' && key !== 'weightKg') {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,9 @@ export async function POST(req: Request) {
|
|||||||
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
|
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 });
|
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();
|
||||||
@@ -76,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,
|
||||||
@@ -85,6 +95,9 @@ export async function POST(req: Request) {
|
|||||||
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,
|
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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -214,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;
|
||||||
@@ -228,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;
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
||||||
refuel/fly/abandon actions push their response snapshot into the shared state */}
|
fly/abandon actions push their response snapshot into the shared state */}
|
||||||
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
|
|||||||
+63
-78
@@ -5,7 +5,7 @@ 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 { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
|||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||||
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
|
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
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;
|
||||||
@@ -101,11 +101,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
||||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
|
|
||||||
// "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
|
|
||||||
// a sensible default (first grounded plane with fuel below max).
|
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||||
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||||
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||||
@@ -202,22 +197,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||||
// market row; with state null both fall back to null and the section isn't rendered.
|
// market row; with state null both fall back to null and the section isn't rendered.
|
||||||
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||||||
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
|
// queued instead of started immediately (see relocate-go below).
|
||||||
const relocatable = state ? state.planes : [];
|
const relocatable = state ? state.planes : [];
|
||||||
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||||
const relocateDest =
|
const relocateDest =
|
||||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
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
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
// destination is already active or queued (the server rejects that with 409). Grounded planes are unaffected.
|
||||||
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
const relocateBlocked =
|
||||||
// market row; grounded-only, and with state null falls back to null.
|
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||||
const grounded = relocatable.filter((p) => p.status === 'available');
|
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||||
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;
|
|
||||||
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
|
||||||
|
|
||||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
// 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
|
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
||||||
@@ -363,19 +352,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="plane-status status-available">{p.locationIcao}</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>
|
||||||
<div className="fuel-bar" aria-hidden="true">
|
<div className="fuel-bar" aria-hidden="true">
|
||||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||||
</div>
|
</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">
|
<div className="plane-sub">
|
||||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
<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 data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
<span className="plane-loc">{planeLocation(p)}</span>
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
</div>
|
</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) => {
|
{cargo.map((pkg) => {
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
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 (
|
return (
|
||||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
// 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
|
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||||
@@ -392,25 +412,19 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`fly-to-${pkg.id}`}
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
className="pane-btn onboard-fly-btn"
|
className="pane-btn onboard-fly-btn"
|
||||||
disabled={actionsBusy}
|
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,
|
// 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.
|
// 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 })}
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
>
|
>
|
||||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
Fly to {pkg.destName}
|
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||||||
|
Fly to {pkg.destIcao}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* ISS-082 (v0.42.0): the per-leg Refuel button is gone — dispatch auto-refuels. */}
|
||||||
<div className="onboard-actions">
|
<div className="onboard-actions">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`refuel-for-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || p.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
|
||||||
>
|
|
||||||
Refuel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`abandon-${pkg.id}`}
|
data-testid={`abandon-${pkg.id}`}
|
||||||
@@ -423,7 +437,18 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
{/* 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>
|
<span>{formatCash(pkg.reward)}</span>
|
||||||
{pkg.status === 'expired' ? (
|
{pkg.status === 'expired' ? (
|
||||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||||
@@ -509,7 +534,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||||
>
|
>
|
||||||
{airportOptions?.map((a) => (
|
{airportOptions?.map((a) => (
|
||||||
<option key={a.icao} value={a.icao}>
|
// 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}
|
{a.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -518,7 +545,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="relocate-go"
|
data-testid="relocate-go"
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
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
|
// 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.
|
// relocation takes off when the current one lands.
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -532,49 +559,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
|
{/* ISS-082 (v0.42.0): the "Refuel to full" section is gone — dispatch auto-refuels instead. */}
|
||||||
about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
|
|
||||||
<section className="pane-section" data-testid="refuel-section">
|
|
||||||
<h3 className="pane-heading">Refuel</h3>
|
|
||||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
|
||||||
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
|
|
||||||
<div className="market-row">
|
|
||||||
{refuelable.length === 0 ? (
|
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
|
||||||
All tanks full.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
data-testid="market-refuel-plane"
|
|
||||||
aria-label="Plane to refuel"
|
|
||||||
value={refuelPlane?.id ?? ''}
|
|
||||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
{refuelable.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span data-testid="market-refuel-cost" className="market-preview">
|
|
||||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="market-refuel"
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || refuelPlane === null}
|
|
||||||
onClick={() => {
|
|
||||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refuel to full
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Time</h3>
|
<h3 className="pane-heading">Time</h3>
|
||||||
|
|||||||
+29
-12
@@ -153,10 +153,20 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
// 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
|
// 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.
|
// 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 idx = airportsIndexRef.current ?? new Map();
|
||||||
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
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(
|
void hoverSource.setData(
|
||||||
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
|
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 —
|
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
||||||
@@ -221,21 +231,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: 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());
|
||||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
// 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 st = stateRef.current;
|
||||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
const planes = st ? st.planes : [];
|
||||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||||
const relocateHtml =
|
const relocateHtml =
|
||||||
groundedPlanes.length > 0
|
planes.length > 0
|
||||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||||
`<option value="">Select a plane\u2026</option>` +
|
`<option value="">Select a plane\u2026</option>` +
|
||||||
groundedPlanes.map(
|
planes.map(
|
||||||
(pl) =>
|
(pl) =>
|
||||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||||
).join('') +
|
).join('') +
|
||||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||||
const popup = new maplibregl.Popup({ offset: 10 })
|
const popup = new maplibregl.Popup({ offset: 10 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
@@ -253,7 +264,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const el = popup.getElement();
|
const el = popup.getElement();
|
||||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
// The status span is always in the popup HTML (even with no planes), so it
|
||||||
// exists whenever the select/button do.
|
// exists whenever the select/button do.
|
||||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||||
if (select && button && statusEl) {
|
if (select && button && statusEl) {
|
||||||
@@ -266,7 +277,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
statusEl.textContent = 'Relocating\u2026';
|
statusEl.textContent = 'Relocating\u2026';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||||
|
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||||
|
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||||
|
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||||
|
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destIcao: p.icao }),
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
@@ -275,7 +290,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||||
statusEl.textContent = 'Relocation started.';
|
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||||
button.disabled = false; // allow retry / picking another plane
|
button.disabled = false; // allow retry / picking another plane
|
||||||
@@ -537,7 +552,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: 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"
|
||||||
@@ -552,7 +569,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: 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" />
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { 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, SELL_FRACTION } from '@/lib/game/catalog';
|
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { maxRangeKm } from '@/lib/sim/movement';
|
import { maxRangeKm } 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 { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -151,6 +151,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
// 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;
|
||||||
|
// 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">
|
||||||
@@ -200,6 +203,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
>
|
>
|
||||||
<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}
|
||||||
@@ -225,7 +234,10 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
</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>
|
||||||
@@ -258,8 +270,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
))}
|
))}
|
||||||
</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"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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: [],
|
queue: [],
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
@@ -39,6 +41,8 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
weightKg: 1_000,
|
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,
|
||||||
|
|||||||
@@ -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,"УВВВ, Самара / Курумоч"
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||||
|
|||||||
+44
-51
@@ -1,5 +1,10 @@
|
|||||||
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
|
||||||
@@ -11,12 +16,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const 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;
|
||||||
|
|
||||||
@@ -72,6 +74,14 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||||
@@ -111,7 +121,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
|
|||||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
// (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×).
|
||||||
@@ -149,7 +163,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
|
|
||||||
|
|
||||||
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
test('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);
|
||||||
@@ -181,8 +195,12 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
// package shows as loaded on the starter in the on-board section.
|
// 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();
|
||||||
@@ -219,21 +237,23 @@ 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). ISS-041: the
|
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
||||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
||||||
@@ -325,9 +345,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
// Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
||||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
||||||
await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
await setScale(page, SCALE);
|
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();
|
||||||
@@ -335,7 +355,7 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
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.
|
||||||
@@ -373,37 +393,10 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
|||||||
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.
|
|
||||||
// ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
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).
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
|||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue',
|
'/api/planes/{planeId}/queue',
|
||||||
'/api/planes/{planeId}/queue/cancel',
|
'/api/planes/{planeId}/queue/cancel',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
|
|||||||
+53
-38
@@ -15,6 +15,8 @@ const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights
|
|||||||
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;
|
||||||
@@ -79,6 +81,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);
|
||||||
@@ -443,9 +453,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
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);
|
||||||
@@ -500,55 +509,52 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
|
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight 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);
|
||||||
|
|
||||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
// ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
// 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' } });
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(load.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
|
const 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 } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// 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');
|
||||||
|
|
||||||
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
|
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
|
||||||
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
|
||||||
const hubLonLat = await airportLonLat(page, HUB);
|
// airport regardless of progress, so no mid-flight position window is needed at all.
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
|
||||||
if (!p || p.status !== 'flying') return false;
|
|
||||||
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
|
|
||||||
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
|
|
||||||
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
|
|
||||||
await postSim(page, { paused: true });
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
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 });
|
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.
|
// Both trace endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
await fitToAirports(page, [HUB, DEST]);
|
await fitToAirports(page, [DEST, QUEUE]);
|
||||||
|
|
||||||
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
|
||||||
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
|
|
||||||
const destLonLat = await airportLonLat(page, DEST);
|
const destLonLat = await airportLonLat(page, DEST);
|
||||||
|
const queueLonLat = await airportLonLat(page, QUEUE);
|
||||||
|
const hubLonLat = await airportLonLat(page, HUB);
|
||||||
|
|
||||||
await row.hover();
|
await row.hover();
|
||||||
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
|
// ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's
|
||||||
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
|
// destination (the queued stop EHAM), and NO segment may start near the departure airport — that
|
||||||
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
// 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 () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||||
const segs = feats.filter(
|
const segs = feats.filter(
|
||||||
@@ -558,11 +564,14 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
if (!segs.length) return false;
|
if (!segs.length) return false;
|
||||||
const near = (a: [number, number], b: [number, number]) =>
|
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;
|
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||||
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
|
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLonLat));
|
||||||
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], 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));
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||||||
return startsAtLive && endsAtDest && noneStartsAtHub;
|
return startsAtDest && endsAtQueue && noneStartsAtHub;
|
||||||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
|
}, { 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 unhover(page);
|
||||||
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||||
@@ -570,9 +579,12 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
message: 'waiting for the hover route trace to clear after mouseleave',
|
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
|
// Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain),
|
||||||
// flying home afterwards leaves the starter clean for the other specs.
|
// resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM,
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
// not DEST — it never delivered), then fly home.
|
||||||
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancelRes.status()).toBe(200);
|
||||||
|
await setScale(page, SCALE); // also clears the pause above
|
||||||
await expect
|
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');
|
||||||
@@ -580,6 +592,9 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
}, { 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);
|
||||||
|
|
||||||
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||||
await setScale(page, SCALE);
|
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 } });
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
||||||
|
import { REPAIR_COST } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||||
|
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||||
|
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||||
|
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
|
||||||
|
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
||||||
|
// passes.
|
||||||
|
|
||||||
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
maxFuel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||||
|
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||||
|
async function setHealth(
|
||||||
|
page: Page,
|
||||||
|
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||||
|
): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||||
|
nearest = f.properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||||
|
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||||
|
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
if (starter.status === 'repairing') {
|
||||||
|
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||||
|
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying') {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.locationIcao !== HUB) {
|
||||||
|
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
|
// leg when cash allows — no top-up retry needed.
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||||
|
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||||
|
async function restoreStarter(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||||
|
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||||
|
await setHealth(page, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||||
|
// and leave a clean starter — full health, no active repair gate.
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await restoreStarter(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
|
||||||
|
|
||||||
|
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// (a) Force health to 42 — snapshot AND UI show it.
|
||||||
|
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.getByTestId(`plane-${STARTER}`);
|
||||||
|
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||||
|
.toContainText('42%');
|
||||||
|
|
||||||
|
// (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
|
||||||
|
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||||
|
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||||
|
.toHaveText('broken', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
|
||||||
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
|
||||||
|
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
|
||||||
|
|
||||||
|
s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.status).toBe('available'); // still grounded…
|
||||||
|
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
|
||||||
|
|
||||||
|
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
|
||||||
|
const cashBefore = s.account.cash;
|
||||||
|
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||||
|
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||||
|
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||||
|
// checked against the exact game clock the server used (a later GET would tick further).
|
||||||
|
const [res] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||||
|
repairBtn.click(),
|
||||||
|
]);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||||
|
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(repaired.status).toBe('repairing');
|
||||||
|
expect(repaired.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
|
||||||
|
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||||
|
.toHaveText('in repair', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||||
|
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||||
|
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.health === 100 ? true : false;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
@@ -93,6 +93,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> {
|
||||||
@@ -198,10 +206,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);
|
||||||
|
|||||||
+12
-7
@@ -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
|
||||||
|
|||||||
+3
-6
@@ -75,13 +75,10 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
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);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a
|
||||||
// heading and button render here…
|
// Refuel heading or row anymore.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
|
|
||||||
// …and neither the heading nor the row remains in the right pane.
|
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||||
|
|||||||
+233
-9
@@ -1,4 +1,11 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
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
|
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||||
@@ -14,7 +21,10 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
|
|||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
|
model: string;
|
||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
queue: string[];
|
queue: string[];
|
||||||
@@ -69,6 +79,25 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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.
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
@@ -201,9 +230,8 @@ 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
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
|
// 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);
|
|
||||||
|
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|
||||||
@@ -233,6 +261,10 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
||||||
|
|
||||||
|
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||||
|
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
|
expect(dupQueue.status()).toBe(409);
|
||||||
|
|
||||||
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
// 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.
|
// posts to /queue and the leg starts when it lands.
|
||||||
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||||||
@@ -268,9 +300,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
|
// Full tank before departure — keeps the state canonical for whatever the cleanup does (ISS-082 hook).
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
// 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].
|
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
||||||
@@ -346,13 +377,70 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
}).toBe(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('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
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
|
// Full tank for a deterministic baseline — the engine auto-chains (and auto-refuels, ISS-082) the return leg regardless.
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
@@ -382,3 +470,139 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
|||||||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
}, { 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
|
||||||
|
});
|
||||||
|
|||||||
+95
-9
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||||||
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
|
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||||
// the existing e2e/map.spec.ts click tests, not here.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
|||||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
@@ -24,6 +25,14 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
const res = await page.request.post('/api/sim', { data: body });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
@@ -96,10 +105,8 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
|||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — a partially drained plane left by an earlier spec could not
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
// afford the cleanup round trip.
|
await setFuelFull(page, 'plane-starter');
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
|
||||||
expect(refuel.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
|
|
||||||
@@ -130,8 +137,8 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
expect(refuel.ok()).toBeTruthy();
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@@ -189,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
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);
|
}, { 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);
|
||||||
|
});
|
||||||
|
|||||||
+123
-114
@@ -1,13 +1,21 @@
|
|||||||
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, 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
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||||
// abandon buttons (penalty preview from the same
|
// abandon buttons (penalty preview from the same
|
||||||
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
||||||
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
|
// the catalog + economy constants exposed in /api/state, and dispatch auto-refuels a low tank to
|
||||||
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
|
// 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; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
@@ -19,6 +27,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;
|
||||||
}
|
}
|
||||||
@@ -32,6 +41,7 @@ interface PackageState {
|
|||||||
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 {
|
||||||
@@ -103,7 +113,8 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
|
// 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 } }>;
|
||||||
};
|
};
|
||||||
@@ -115,32 +126,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 —
|
||||||
@@ -157,10 +143,11 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
|
|||||||
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// 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;
|
||||||
@@ -168,7 +155,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
|||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// 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×).
|
||||||
@@ -184,10 +175,11 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||||
// fast-forwards instead of taking real-time hours.
|
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
|
||||||
|
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
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,
|
||||||
@@ -195,6 +187,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 }) => {
|
||||||
@@ -210,13 +210,20 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
await expect(rows.first(), '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();
|
||||||
|
|
||||||
@@ -231,6 +238,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
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
|
||||||
@@ -308,7 +332,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('market buy/sell render with previews from the catalog in /api/state; left-pane 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)
|
||||||
|
|
||||||
@@ -321,11 +345,20 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
|
// 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();
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
// 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
|
||||||
@@ -335,104 +368,80 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
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));
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
|
|
||||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 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; left-pane 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);
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Left pane (moved from the market in v0.23.0, ISS-035): 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. ISS-041: the far-end
|
|
||||||
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
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('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||||||
|
|||||||
@@ -80,8 +80,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
||||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
// leg when cash allows — no top-up retry needed.
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
expect(a.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+49
-73
@@ -1,12 +1,17 @@
|
|||||||
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 { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
||||||
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
|
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
|
||||||
//
|
//
|
||||||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
// 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
|
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||||
@@ -114,18 +119,25 @@ async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
|
// 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).
|
// 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 }> {
|
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 entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
|
||||||
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 } }>;
|
||||||
};
|
};
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
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;
|
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
||||||
for (const f of fc.features) {
|
for (const f of fc.features) {
|
||||||
if (f.properties.icao === HUB) continue;
|
if (f.properties.icao === HUB) continue;
|
||||||
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
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
|
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) {
|
if (!farthest || d > farthest.d) {
|
||||||
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
||||||
}
|
}
|
||||||
@@ -157,6 +169,14 @@ async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
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
|
// 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).
|
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||||||
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||||||
@@ -230,13 +250,10 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (p.locationIcao !== HUB) {
|
if (p.locationIcao !== HUB) {
|
||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
|
// leg when cash allows — no top-up retry needed.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
if (flyHome.status() === 409) {
|
|
||||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
|
||||||
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
|
|
||||||
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
|
||||||
}
|
|
||||||
expect(flyHome.status()).toBe(200);
|
expect(flyHome.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||||||
@@ -261,8 +278,11 @@ async function sellNonStarters(page: Page): Promise<void> {
|
|||||||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
// 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.
|
// no cargo on board — even after an interrupted previous run.
|
||||||
async function resetFleet(page: Page): Promise<void> {
|
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 sellNonStarters(page);
|
||||||
await healPlaneHome(page, STARTER);
|
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||||
await abandonLeftoverCargo(page, STARTER);
|
await abandonLeftoverCargo(page, STARTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,8 +353,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
const secondId = await buySecondPlaneViaUi(page);
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
// Full tanks for both — the starter's may be partial from an earlier run.
|
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, STARTER);
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
// 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
|
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||||
@@ -385,8 +405,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||||
expect(landed.packages.find((p) => p.id === pkgB.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 up for the return leg.
|
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||||||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||||||
@@ -401,62 +421,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
|
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||||
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
|
// its absence is pinned in e2e/panes.spec.ts instead.
|
||||||
const secondId = await buySecondPlaneViaUi(page);
|
|
||||||
|
|
||||||
// Partial tanks: a short hop each, to different destinations.
|
|
||||||
const [a, b] = await twoNearestToHub(page);
|
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
|
||||||
|
|
||||||
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
|
|
||||||
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 setScale(page, SCALE);
|
|
||||||
await expect
|
|
||||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
|
||||||
timeout: 240_000,
|
|
||||||
message: 'waiting for both planes to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const pre = await getState(page);
|
|
||||||
const starter = pre.planes.find((p) => p.id === STARTER)!;
|
|
||||||
const second = pre.planes.find((p) => p.id === secondId)!;
|
|
||||||
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
|
|
||||||
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
|
|
||||||
|
|
||||||
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
|
|
||||||
const refuelSelect = page.getByTestId('market-refuel-plane');
|
|
||||||
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
|
||||||
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
|
||||||
|
|
||||||
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
|
|
||||||
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100;
|
|
||||||
await refuelSelect.selectOption(secondId);
|
|
||||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
|
||||||
|
|
||||||
await page.getByTestId('market-refuel').click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const s = await getState(page);
|
|
||||||
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
|
|
||||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const post = await getState(page);
|
|
||||||
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
|
|
||||||
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||||||
@@ -508,7 +474,7 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
const [a, b] = await twoNearestToHub(page);
|
const [a, b] = await twoNearestToHub(page);
|
||||||
|
|
||||||
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, STARTER);
|
||||||
|
|
||||||
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
// 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/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||||
@@ -529,20 +495,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
await expect(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);
|
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||||||
|
|
||||||
// Fly the STARTER to its first queued destination…
|
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
|
||||||
|
// response snapshot must already show only [b] left (previously the leg stayed queued and the
|
||||||
|
// landing chained a zero-distance self-hop a→a before reaching b).
|
||||||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||||
expect(flyRes.status()).toBe(200);
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||||
|
|
||||||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||||||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
|
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
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;
|
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||||
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
|
||||||
|
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||||
|
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||||
|
|
||||||
// …then restore speed so the chained leg fast-forwards too.
|
// …then restore speed so the chained leg fast-forwards too.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
+22
-13
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS planes (
|
|||||||
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 '[]'
|
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,
|
||||||
@@ -41,6 +43,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
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,
|
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,
|
||||||
@@ -83,6 +86,18 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('packages').has('weight_kg')) {
|
if (!cols('packages').has('weight_kg')) {
|
||||||
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
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
|
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||||
|
|||||||
+11
-1
@@ -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(),
|
||||||
@@ -24,6 +26,12 @@ export const planes = sqliteTable('planes', {
|
|||||||
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||||
queue: text('queue').notNull().default('[]'),
|
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
|
||||||
@@ -40,6 +48,8 @@ export const packages = sqliteTable('packages', {
|
|||||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
|
// 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.
|
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
|
||||||
weightKg: real('weight_kg').notNull().default(2000),
|
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.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50.03,
|
lat: 50.03,
|
||||||
lon: 8.56,
|
lon: 8.56,
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,8 +36,10 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
reward: 1234.5,
|
reward: 1234.5,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
weightKg: 600,
|
weightKg: 600,
|
||||||
deadlineSimTime: 0,
|
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',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
|||||||
+18
-1
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+271
-134
@@ -6,9 +6,21 @@ 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 {
|
||||||
|
FUEL_DISCOUNT_PER_REP,
|
||||||
|
FUEL_PRICE_PER_L,
|
||||||
|
MAX_FUEL_DISCOUNT,
|
||||||
|
MAX_PLANE_DISCOUNT,
|
||||||
|
PLANE_DISCOUNT_PER_REP,
|
||||||
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
|
repRate,
|
||||||
|
SELL_FRACTION,
|
||||||
|
} from './catalog';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
@@ -19,7 +31,7 @@ import {
|
|||||||
ignorePackage,
|
ignorePackage,
|
||||||
loadPackage,
|
loadPackage,
|
||||||
queueLeg,
|
queueLeg,
|
||||||
refuelPlane,
|
repairPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
@@ -217,11 +229,135 @@ 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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,6 +397,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
const inst = await freshDb('qleg-unknownicao.db');
|
const inst = await freshDb('qleg-unknownicao.db');
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 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)', () => {
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
@@ -455,6 +622,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', () => {
|
||||||
@@ -500,6 +699,19 @@ describe('sellPlane', () => {
|
|||||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
@@ -511,156 +723,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(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+103
-72
@@ -3,18 +3,30 @@ 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, parseIcaoQueue } 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:
|
||||||
@@ -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,13 @@ 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 });
|
||||||
|
}
|
||||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||||
@@ -153,7 +201,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
throw new GameActionError(400, `unknown airport ${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);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.includes(destIcao)) {
|
||||||
|
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
|
||||||
|
}
|
||||||
queue.push(destIcao);
|
queue.push(destIcao);
|
||||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
@@ -231,16 +288,19 @@ export async function ignorePackage(inst: GameDb, packageId: string): Promise<vo
|
|||||||
|
|
||||||
// --- 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();
|
||||||
@@ -259,8 +319,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 };
|
||||||
}
|
}
|
||||||
@@ -286,6 +347,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
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');
|
||||||
|
|
||||||
@@ -301,74 +367,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) ---
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
|
import { STARTING_REPUTATION } from './seed';
|
||||||
|
|
||||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||||
describe('PLANE_TYPES', () => {
|
describe('PLANE_TYPES', () => {
|
||||||
@@ -22,3 +23,23 @@ describe('PLANE_TYPES', () => {
|
|||||||
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+33
-2
@@ -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;
|
||||||
@@ -96,15 +100,42 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
|||||||
// 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;
|
||||||
|
|||||||
+201
-21
@@ -1,19 +1,25 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
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_KG,
|
||||||
REWARD_PER_KM,
|
REWARD_PER_KM,
|
||||||
rewardFor,
|
rewardFor,
|
||||||
STARTING_CASH,
|
STARTING_CASH,
|
||||||
STARTING_REPUTATION,
|
STARTING_REPUTATION,
|
||||||
|
type Rng,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
} from './seed';
|
} from './seed';
|
||||||
|
|
||||||
@@ -46,6 +52,7 @@ describe('buildSeed', () => {
|
|||||||
lat: 50.0333,
|
lat: 50.0333,
|
||||||
lon: 8.5706,
|
lon: 8.5706,
|
||||||
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full health on first run
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,14 +78,14 @@ describe('buildSeed', () => {
|
|||||||
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('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
|
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(
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,11 +101,13 @@ describe('buildSeed', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
it('sets deadlines inside the 6–72h game-time window after now (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);
|
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||||
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
|
||||||
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,6 +134,9 @@ describe('buildSeed', () => {
|
|||||||
it('rewardFor applies the documented formula', () => {
|
it('rewardFor applies the documented formula', () => {
|
||||||
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
||||||
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,13 +155,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(
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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', () => {
|
||||||
@@ -191,9 +204,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(
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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', () => {
|
||||||
@@ -209,9 +222,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(
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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', () => {
|
||||||
@@ -242,7 +255,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) + REWARD_PER_KG * 1_234));
|
// 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…
|
// Deterministic for a fixed RNG…
|
||||||
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
||||||
@@ -252,4 +267,169 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-6
@@ -1,5 +1,6 @@
|
|||||||
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 { 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
|
||||||
@@ -41,6 +42,8 @@ export interface SeedPlane {
|
|||||||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
// 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.
|
// column as JSON text on insert; defaults to an empty queue.
|
||||||
queue?: string[];
|
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 {
|
||||||
@@ -52,6 +55,8 @@ export interface SeedPackage {
|
|||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
||||||
weightKg: number;
|
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;
|
||||||
@@ -85,9 +90,29 @@ export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
|||||||
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
||||||
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
||||||
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
// 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 = 0.5;
|
export const REWARD_PER_KG = 2.0;
|
||||||
|
|
||||||
|
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||||||
|
// higher reputation, exactly ×1 at the starting value.
|
||||||
|
// 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;
|
||||||
@@ -116,12 +141,17 @@ export function rewardFor(km: number, weightKg: number): number {
|
|||||||
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
|
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
|
||||||
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
|
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
|
||||||
// force a near-term expiry). The RNG call order per package is fixed: origin,
|
// force a near-term expiry). The RNG call order per package is fixed: origin,
|
||||||
// dest, cargoType, cargoCount, weight jitter, [deadline window].
|
// 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
|
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
||||||
// jitter draw, same pattern as forced endpoints.
|
// 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[];
|
||||||
@@ -131,6 +161,8 @@ export function generatePackage(input: {
|
|||||||
originIcao?: string;
|
originIcao?: string;
|
||||||
destIcao?: string;
|
destIcao?: string;
|
||||||
weightKg?: number;
|
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');
|
||||||
@@ -152,6 +184,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];
|
||||||
@@ -165,17 +218,28 @@ export function generatePackage(input: {
|
|||||||
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
||||||
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
|
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
|
||||||
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
||||||
const deadlineMs =
|
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
|
||||||
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
// 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: rewardFor(km, weightKg),
|
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
||||||
cargoType,
|
cargoType,
|
||||||
cargoCount,
|
cargoCount,
|
||||||
weightKg,
|
weightKg,
|
||||||
|
special,
|
||||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
@@ -183,6 +247,19 @@ export function generatePackage(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
|
||||||
|
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
|
||||||
|
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
|
||||||
|
export function pickDestWithinCap(airports: SeedAirport[], originIdx: number, rng: Rng): number {
|
||||||
|
const candidates: number[] = [];
|
||||||
|
for (let j = 0; j < airports.length; j++) {
|
||||||
|
if (j === originIdx) continue;
|
||||||
|
if (haversineKm(airports[originIdx].lat, airports[originIdx].lon, airports[j].lat, airports[j].lon) <= MAX_JOB_LEG_KM) candidates.push(j);
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return -1;
|
||||||
|
return candidates[Math.floor(rng() * candidates.length)];
|
||||||
|
}
|
||||||
|
|
||||||
function pickHub(airports: SeedAirport[]): SeedAirport {
|
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];
|
||||||
}
|
}
|
||||||
@@ -209,6 +286,7 @@ export function buildSeed(input: {
|
|||||||
lat: hub.lat,
|
lat: hub.lat,
|
||||||
lon: hub.lon,
|
lon: hub.lon,
|
||||||
queue: [], // no queued legs on first run (v0.25.0)
|
queue: [], // no queued legs on first run (v0.25.0)
|
||||||
|
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||||
};
|
};
|
||||||
|
|
||||||
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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
+37
-5
@@ -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;
|
||||||
@@ -27,6 +29,12 @@ export interface PlaneState {
|
|||||||
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
// 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.
|
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||||
queue: string[];
|
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
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||||
@@ -52,6 +60,10 @@ export interface PackageState {
|
|||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
||||||
weightKg: number;
|
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;
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||||
@@ -65,7 +77,7 @@ export interface StateSnapshot {
|
|||||||
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;
|
||||||
@@ -90,6 +102,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.
|
||||||
@@ -102,7 +120,17 @@ 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 {
|
||||||
@@ -127,6 +155,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
lat: live?.lat ?? p.lat,
|
lat: live?.lat ?? p.lat,
|
||||||
lon: live?.lon ?? p.lon,
|
lon: live?.lon ?? p.lon,
|
||||||
queue: parseIcaoQueue(p.queue),
|
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) => ({
|
||||||
@@ -139,6 +169,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
cargoType: p.cargoType,
|
cargoType: p.cargoType,
|
||||||
cargoCount: p.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
weightKg: p.weightKg,
|
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
@@ -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
@@ -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;
|
||||||
|
|||||||
+56
-21
@@ -34,8 +34,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, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ 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, queue: [] },
|
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
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, queue: [] },
|
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -72,7 +72,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, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -89,7 +89,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, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -101,7 +101,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, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -118,7 +118,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, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -130,8 +130,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, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ 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, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -140,11 +140,37 @@ 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)', () => {
|
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => {
|
||||||
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ 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, queue: ['EHAM'] },
|
// 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,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -154,7 +180,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a flying plane with an empty queue', () => {
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -165,7 +191,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -182,24 +208,27 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||||
{ 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, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ 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, queue: ['LEMD'] },
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
|
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)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,6 +283,12 @@ describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the
|
|||||||
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
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: [] });
|
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)', () => {
|
||||||
|
|||||||
+20
-9
@@ -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;
|
||||||
@@ -115,7 +116,10 @@ export function hoverRouteGeoJson(
|
|||||||
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
// 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
|
// 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
|
// 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.
|
// 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(
|
export function hoverTraceGeoJson(
|
||||||
from: AirportCoord,
|
from: AirportCoord,
|
||||||
toIcao: string,
|
toIcao: string,
|
||||||
@@ -123,6 +127,7 @@ export function hoverTraceGeoJson(
|
|||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const to = airportsByIcao.get(toIcao);
|
const to = airportsByIcao.get(toIcao);
|
||||||
if (!to) return EMPTY_LINES;
|
if (!to) return EMPTY_LINES;
|
||||||
|
if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: [
|
features: [
|
||||||
@@ -165,19 +170,25 @@ export function routesGeoJson(
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
|
// ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue,
|
||||||
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
|
// chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination
|
||||||
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
|
// (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer),
|
||||||
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
|
// and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current
|
||||||
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
|
// 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(
|
export function queuedRoutesGeoJson(
|
||||||
planes: PlaneState[],
|
planes: PlaneState[],
|
||||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const features: MapLineFeature[] = [];
|
const features: MapLineFeature[] = [];
|
||||||
for (const p of planes) {
|
for (const p of planes) {
|
||||||
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
|
if (p.queue.length === 0) continue;
|
||||||
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
// 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
|
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
||||||
const coords: [number, number][] = [];
|
const coords: [number, number][] = [];
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
|||||||
+11
-16
@@ -14,7 +14,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
@@ -115,6 +115,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',
|
||||||
@@ -123,6 +124,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -134,11 +136,13 @@ 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
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
]);
|
]);
|
||||||
@@ -191,7 +195,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/queue/cancel', // 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');
|
||||||
@@ -202,14 +206,13 @@ 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']); // v0.25.0 — no 409: queueing works grounded or flying
|
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/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',
|
||||||
);
|
);
|
||||||
@@ -229,9 +232,7 @@ 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);
|
||||||
@@ -240,7 +241,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
||||||
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
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}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
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;
|
||||||
@@ -262,11 +263,5 @@ describe('OpenAPI spec', () => {
|
|||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-50
@@ -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. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
||||||
operationId: 'getState',
|
operationId: 'getState',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -194,7 +194,7 @@ export const openApiSpec = {
|
|||||||
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' } },
|
||||||
@@ -223,7 +223,7 @@ 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' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,7 +233,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Queue a flight leg for a plane',
|
summary: 'Queue a flight leg for a plane',
|
||||||
description:
|
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 (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).',
|
'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',
|
operationId: 'queueLeg',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -260,6 +260,11 @@ export const openApiSpec = {
|
|||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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' } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -301,7 +306,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,
|
||||||
@@ -361,39 +366,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' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -471,7 +463,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'],
|
||||||
@@ -481,9 +473,9 @@ 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.' },
|
||||||
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).' },
|
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).' },
|
||||||
@@ -494,7 +486,7 @@ export const openApiSpec = {
|
|||||||
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'],
|
||||||
},
|
},
|
||||||
@@ -562,22 +554,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: {
|
||||||
@@ -590,7 +566,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' },
|
||||||
@@ -615,6 +591,16 @@ export const openApiSpec = {
|
|||||||
description:
|
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.',
|
'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',
|
||||||
@@ -630,6 +616,8 @@ export const openApiSpec = {
|
|||||||
'lat',
|
'lat',
|
||||||
'lon',
|
'lon',
|
||||||
'queue',
|
'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: {
|
||||||
@@ -644,6 +632,8 @@ export const openApiSpec = {
|
|||||||
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.' },
|
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.
|
||||||
@@ -662,6 +652,7 @@ export const openApiSpec = {
|
|||||||
'cargoType',
|
'cargoType',
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
'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',
|
||||||
|
|||||||
+283
-23
@@ -6,14 +6,17 @@ 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 { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
clampTimeScale,
|
clampTimeScale,
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expirePackages,
|
||||||
|
finishRepairs,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
MAX_TIME_SCALE,
|
MAX_TIME_SCALE,
|
||||||
MIN_TIME_SCALE,
|
MIN_TIME_SCALE,
|
||||||
@@ -165,7 +168,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
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,
|
||||||
@@ -184,8 +187,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
|
||||||
|
|
||||||
@@ -230,6 +236,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.
|
||||||
@@ -241,14 +267,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
|
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
|
||||||
|
|
||||||
it('auto-chains to the next queued leg when fuel covers it', async () => {
|
it('auto-chains to the next queued leg when fuel covers it', async () => {
|
||||||
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
|
const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
|
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
|
||||||
|
|
||||||
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
|
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
|
||||||
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
|
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
|
||||||
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
|
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)
|
await inst.db.update(schema.planes)
|
||||||
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
||||||
@@ -267,19 +298,64 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
|
it('auto-refuels a short tank and chains the next queued leg when cash allows (ISS-082)', async () => {
|
||||||
const { inst, km, type } = await setupFlight('chain-nofuel.db');
|
const { inst, from, to, km, type } = await setupFlight('chain-autorefuel.db');
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
|
||||||
|
|
||||||
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
|
// 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)
|
await inst.db.update(schema.planes)
|
||||||
.set({
|
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
|
||||||
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
|
|
||||||
queue: JSON.stringify(['EHAM']),
|
|
||||||
})
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable
|
||||||
|
const 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
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
@@ -287,7 +363,10 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(plane.status).toBe('available'); // grounded safely…
|
expect(plane.status).toBe('available'); // grounded safely…
|
||||||
expect(plane.destIcao).toBeNull();
|
expect(plane.destIcao).toBeNull();
|
||||||
expect(plane.departureSimTime).toBeNull();
|
expect(plane.departureSimTime).toBeNull();
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for refuel + re-dispatch
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch
|
||||||
|
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
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 () => {
|
it('grounds as before when the queue is empty', async () => {
|
||||||
@@ -299,6 +378,186 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||||
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
|
||||||
|
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
|
||||||
|
|
||||||
|
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
|
||||||
|
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
|
||||||
|
const wTo = weatherFor(TO);
|
||||||
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
|
||||||
|
const nextRequiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * fNext,
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
|
||||||
|
);
|
||||||
|
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['FGSL']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
|
||||||
|
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
|
||||||
|
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
|
||||||
|
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||||
|
const FROM = 'EDDF';
|
||||||
|
const TO = 'LEMD';
|
||||||
|
|
||||||
|
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
|
||||||
|
async function setupFlight(name: string) {
|
||||||
|
const inst = await freshDb(name);
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
return { inst, from, to, km, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||||
|
const { inst, km } = await setupFlight('wear-leg.db');
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at 0 across multiple legs', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
|
||||||
|
// Start below one leg's wear so the first landing clamps…
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // clamped — never negative…
|
||||||
|
|
||||||
|
// …and a second leg keeps it pinned at 0.
|
||||||
|
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
|
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
|
||||||
|
const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
|
||||||
|
// Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
|
||||||
|
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
|
||||||
|
// guard can explain why it doesn't chain.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // broken by this landing's wear…
|
||||||
|
expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||||
|
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
|
||||||
|
const inst = await freshDb('repair-finish.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||||
|
const inst = await freshDb('repair-tick.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
||||||
|
await tickOnce(inst);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
|
||||||
|
const inst = await freshDb('repair-curve.db');
|
||||||
|
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
|
||||||
|
const gate = T0 + REPAIR_DURATION_MS;
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
|
||||||
|
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
expect(plane.health).toBe(50);
|
||||||
|
|
||||||
|
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
|
||||||
|
const inst2 = await freshDb('repair-curve-floor.db');
|
||||||
|
await inst2.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
|
||||||
|
const plane2 = (await inst2.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
|
||||||
|
|
||||||
|
// At the gate: exactly 100 and back in service.
|
||||||
|
expect(await finishRepairs(inst, gate)).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.health).toBe(100);
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||||
@@ -424,9 +683,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.
|
||||||
|
|||||||
+136
-20
@@ -3,17 +3,20 @@ 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 { 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, requiredFuelFor } 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) — tick-on-read: there is NO background
|
||||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||||||
@@ -77,6 +80,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
await expirePackages(inst, clockMs);
|
await expirePackages(inst, clockMs);
|
||||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||||
landedThisTick = await deliverPackages(inst, clockMs);
|
landedThisTick = await deliverPackages(inst, clockMs);
|
||||||
|
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||||
|
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||||
|
await finishRepairs(inst, clockMs);
|
||||||
await spawnOpenPackages(inst, clockMs);
|
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.
|
||||||
@@ -124,14 +130,48 @@ 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):
|
||||||
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||||
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||||
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
// auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
|
||||||
// immediately from the arrival airport; otherwise ground the plane there with
|
// broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
|
||||||
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
// 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
|
// 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.
|
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||||||
@@ -156,8 +196,20 @@ 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 —
|
||||||
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
|
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||||||
|
const w = weatherFor(p.locationIcao);
|
||||||
|
const landedFuel = fuelAtWeighted(
|
||||||
|
p.fuel,
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
distanceKm,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 —
|
// Deliver every bound package whose destination is the airport just reached —
|
||||||
// runs whether or not a queued leg chains next (ISS-043).
|
// runs whether or not a queued leg chains next (ISS-043).
|
||||||
@@ -168,9 +220,12 @@ 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.
|
||||||
@@ -180,17 +235,56 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
|
|
||||||
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
// 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
|
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
||||||
// (requiredFuelFor is independent of current load). Otherwise ground here; an
|
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
||||||
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
|
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
||||||
// intact either way so a grounded plane can refuel and re-dispatch.
|
// 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);
|
const queue = parseIcaoQueue(p.queue);
|
||||||
let chainedTo: string | null = null;
|
let chainedTo: string | null = null;
|
||||||
if (queue.length > 0) {
|
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||||
|
if (queue.length > 0 && newHealth > 0) {
|
||||||
const nextDest = queue[0];
|
const nextDest = queue[0];
|
||||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
|
||||||
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
|
// 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();
|
queue.shift();
|
||||||
chainedTo = nextDest;
|
} 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)
|
await inst.db.update(schema.planes)
|
||||||
@@ -201,7 +295,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
locationIcao: to.icao,
|
locationIcao: to.icao,
|
||||||
lat: to.lat,
|
lat: to.lat,
|
||||||
lon: to.lon,
|
lon: to.lon,
|
||||||
fuel: landedFuel,
|
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,
|
destIcao: chainedTo,
|
||||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||||
queue: JSON.stringify(queue),
|
queue: JSON.stringify(queue),
|
||||||
@@ -212,6 +307,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
lat: to.lat,
|
lat: to.lat,
|
||||||
lon: to.lon,
|
lon: to.lon,
|
||||||
fuel: landedFuel,
|
fuel: landedFuel,
|
||||||
|
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
destIcao: null,
|
destIcao: null,
|
||||||
queue: JSON.stringify(queue),
|
queue: JSON.stringify(queue),
|
||||||
@@ -245,9 +341,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;
|
||||||
}
|
}
|
||||||
@@ -289,12 +390,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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
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
|
||||||
@@ -166,6 +167,53 @@ describe('weighted burn model (v0.16.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)', () => {
|
describe('maxRangeKm (v0.28.0)', () => {
|
||||||
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
|
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
|
||||||
const r = maxRangeKm(BURN, WE, TANK);
|
const r = maxRangeKm(BURN, WE, TANK);
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ export function requiredFuelFor(
|
|||||||
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:
|
// 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).
|
// (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
|
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.33.0",
|
"version": "0.49.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
@@ -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(', ')}`);
|
||||||
Reference in New Issue
Block a user