Compare commits

..
Author SHA1 Message Date
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
kamilkrzywda 1401a2f6eb Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s
- flyPlane 409s a broken plane (health <= 0) before any refuel math — fuel/cash untouched
- engine auto-chain skips chaining when a landing's wear breaks the plane (queue kept intact)
- finishRepairs heals along round(f x 100) each tick; flip to available at exactly 100 on gate pass
- repairPlane no longer jumps health to 100 — it starts the gradual heal
- REMOVED BROKEN_BURN_MULTIPLIER / conditionBurnMultiplier and all burn sites (unreachable now)
- OpenAPI + route comments reworded; unit tests reworked; e2e maintenance.spec.ts per issue
- v0.45.0
2026-09-21 21:17:51 +00:00
kamilkrzywda 0de0aaffd9 Merge pull request 'World airports + flyable job range (ISS-079, v0.44.0)' (#91) from feat/world-airports into master
CI / build + unit + e2e (push) Successful in 8m41s
2026-09-21 21:48:50 +02:00
kamilkrzywda 3ac7075eb0 World airport dataset + distance cap on generated jobs (ISS-079, v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 11m2s
2026-09-21 19:28:44 +00:00
kamilkrzywda 320bdf3b72 Merge pull request 'Wear 10× slower — maintenance lasts longer (ISS-085, v0.43.0)' (#87) from feat/wear-slowdown into master
CI / build + unit + e2e (push) Successful in 7m50s
2026-09-21 18:13:30 +02:00
38 changed files with 2110 additions and 492 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
## v0.47.0 — 2026-09-21 23:01
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
## v0.44.0 — 2026-09-21 17:10
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
- **Generated jobs are distance-capped.** New `MAX_JOB_LEG_KM = 9000` in `lib/game/seed.ts`: every spawned leg is flyable by at least one catalog plane even in worst-case headwind (b777 full-tank range ~13045 km nominal, ~10871 km with the +20% wind factor — 9000 leaves margin). `generatePackage` rejection-samples unforced origin/dest pairs until they fit (bounded at 25 attempts, then a uniform within-cap fallback); forced endpoints consume no extra draws and stay uncapped (the UNNT→LPPD 409 backstop relies on that), so under-cap RNG streams are byte-identical to before. The engine's coverage spawn caps its random dest through the shared `pickDestWithinCap` helper — one draw in the same stream position as before
- **UI/spec/docs.** Map header label → "N world airports" (e2e regex updated), OpenAPI `/api/airports` summary/description reworded to the curated world dataset, PLAN.md dataset mentions updated; initial camera stays EU-tuned (out of scope)
- **E2E marker isolation re-verified.** LFRS (Nantes) keeps ≥39 px clearance from its nearest markers (LFRB Brest / LFBD Bordeaux) at the initial zoom [10,52] z4 with the world dataset — no test changes needed beyond the label regex
## v0.43.0 — 2026-09-21 15:57 ## 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) - **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)
+2 -3
View File
@@ -6,7 +6,7 @@ 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, maintenance). No manual refueling — dispatch auto-tops-up the tank to 150% of the leg requirement (capped at capacity) when cash allows - 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)
+5 -5
View File
@@ -1,10 +1,10 @@
import { performAction, repairPlane } from '@/lib/game/actions'; import { performAction, repairPlane } from '@/lib/game/actions';
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to // POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days // "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
// pass, when the engine's tick flips it back to "available". A full-health repair is a free // the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success // repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
// returns the full state snapshot. // On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params; const { planeId } = await ctx.params;
+14 -1
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route'; import { GET } from './route';
@@ -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 () => {
+54
View File
@@ -0,0 +1,54 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
// stays for relative grants, this one is exact.
//
// POST /api/test/set-cash
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
// account.cash to exactly that value and returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const amount = input.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+3 -1
View File
@@ -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, formatSpecialLabel } 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';
@@ -441,6 +441,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route"> <span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao} {pkg.originIcao} → {pkg.destIcao}
</span> </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. */} {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && ( {pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge"> <span data-testid={`package-special-${pkg.id}`} className="special-badge">
+1 -1
View File
@@ -564,7 +564,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" />
+3 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, 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 { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } 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';
@@ -234,6 +234,8 @@ 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>
{/* 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". */} {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span> <span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
+1
View File
@@ -42,6 +42,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoCount: 4, cargoCount: 4,
weightKg: 1_000, weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
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,
-243
View File
@@ -1,243 +0,0 @@
id,ident,type,name,latitude_deg,longitude_deg,elevation_ft,continent,iso_country,iso_region,municipality,scheduled_service,icao_code,iata_code,gps_code,local_code,home_link,wikipedia_link,keywords
117,BIAR,large_airport,Akureyri International Airport,65.656573,-18.072018,6,EU,IS,IS-6,Akureyri,yes,BIAR,AEY,BIAR,,https://www.innanlandsflugvellir.is/en/akureyri-airport/,https://en.wikipedia.org/wiki/Akureyri_Airport,
123,BIKF,large_airport,Keflavik International Airport,63.985001,-22.6056,171,EU,IS,IS-2,Reykjavík,yes,BIKF,KEF,BIKF,,https://www.isavia.is/en/keflavik-airport,https://en.wikipedia.org/wiki/Keflav%C3%ADk_International_Airport,"Keflavik Naval Air Station, REK"
4614,BKPR,large_airport,Priština Adem Jashari International Airport,42.5728,21.035801,1789,EU,XK,XK-01,Prishtina,yes,BKPR,PRN,BKPR,,http://www.airportpristina.com/,https://en.wikipedia.org/wiki/Pristina_International_Airport,"LYPR, Pristina, Slatina Air Base"
2155,EBBR,large_airport,Brussels Airport,50.901402,4.48444,175,EU,BE,BE-VBR,Zaventem,yes,EBBR,BRU,EBBR,,http://www.brusselsairport.be/en/,https://en.wikipedia.org/wiki/Brussels_Airport,"Brussels National, Zaventem, EBMB"
2163,EBOS,large_airport,Ostend-Bruges International Airport,51.1998,2.874673,13,EU,BE,BE-VWV,Oostende,yes,EBOS,OST,EBOS,,http://www.ost.aero/,https://en.wikipedia.org/wiki/Ostend-Bruges_International_Airport,Ostend-Bruges International Airport
301881,EDDB,large_airport,Berlin Brandenburg Airport,52.361738,13.502341,157,EU,DE,DE-BR,Berlin,yes,EDDB,BER,EDDB,,https://ber.berlin-airport.de/,https://en.wikipedia.org/wiki/Berlin_Brandenburg_Airport,Willy Brandt
2210,EDDC,large_airport,Dresden Airport,51.134123,13.767831,755,EU,DE,DE-SN,Dresden,yes,EDDC,DRS,EDDC,,http://www.dresden-airport.de/en/index.html,https://en.wikipedia.org/wiki/Dresden_Airport,ETDN
2211,EDDE,large_airport,Erfurt-Weimar Airport,50.978281,10.960713,1036,EU,DE,DE-TH,Erfurt,yes,EDDE,ERF,EDDE,,http://www.airport-erfurt.de/,https://en.wikipedia.org/wiki/Erfurt_Airport,Flughafen Erfurt-Weimar
2212,EDDF,large_airport,Frankfurt Main Airport,50.026706,8.55835,364,EU,DE,DE-HE,Frankfurt am Main,yes,EDDF,FRA,EDDF,,https://www.frankfurt-airport.de/,https://en.wikipedia.org/wiki/Frankfurt_Airport,"EDAF, Frankfurt am Main, Frankfurt Main, Rhein-Main Air Base, Zeppelinheim"
2213,EDDG,large_airport,Münster Osnabrück Airport,52.133816,7.688482,160,EU,DE,DE-NW,Greven,yes,EDDG,FMO,EDDG,,https://www.fmo.de/,https://en.wikipedia.org/wiki/M%C3%BCnster_Osnabr%C3%BCck_International_Airport,
2214,EDDH,large_airport,Hamburg Helmut Schmidt Airport,53.630402,9.98823,53,EU,DE,DE-HH,Hamburg,yes,EDDH,HAM,EDDH,,https://www.hamburg-airport.de/en/,https://en.wikipedia.org/wiki/Hamburg_Airport,Hamburg-Fuhlsbüttel Airport
2216,EDDK,large_airport,Cologne Bonn Airport,50.865898,7.14274,302,EU,DE,DE-NW,Köln (Cologne),yes,EDDK,CGN,EDDK,,http://www.koeln-bonn-airport.de/en/,https://en.wikipedia.org/wiki/Cologne_Bonn_Airport,Köln
2217,EDDL,large_airport,Düsseldorf Airport,51.289501,6.76678,147,EU,DE,DE-NW,Düsseldorf,yes,EDDL,DUS,EDDL,,http://www.dus.com/dus_en/,https://en.wikipedia.org/wiki/D%C3%BCsseldorf_Airport,
2218,EDDM,large_airport,Munich Airport,48.353802,11.7861,1487,EU,DE,DE-BY,Munich,yes,EDDM,MUC,EDDM,,http://www.munich-airport.com/,https://en.wikipedia.org/wiki/Munich_Airport,"Franz Josef Strauss Airport, Flughafen München Franz Josef Strauß"
2219,EDDN,large_airport,Nuremberg Airport,49.498699,11.078056,1046,EU,DE,DE-BY,Nuremberg,yes,EDDN,NUE,EDDN,,https://www.airport-nuernberg.de/english,https://en.wikipedia.org/wiki/Nuremberg_Airport,Nürnberg
2220,EDDP,large_airport,Leipzig/Halle Airport,51.420657,12.232705,465,EU,DE,DE-SN,Schkeuditz,yes,EDDP,LEJ,EDDP,,https://www.mdf-ag.com/,https://en.wikipedia.org/wiki/Leipzig/Halle_Airport,Schkeuditz Airport
2222,EDDS,large_airport,Stuttgart Airport,48.689899,9.22196,1276,EU,DE,DE-BW,Stuttgart,yes,EDDS,STR,EDDS,,http://www.flughafen-stuttgart.de/,https://en.wikipedia.org/wiki/Stuttgart_Airport,
2224,EDDV,large_airport,Hannover Airport,52.461102,9.68508,183,EU,DE,DE-NI,Hannover,yes,EDDV,HAJ,EDDV,,https://www.hannover-airport.de,https://en.wikipedia.org/wiki/Hanover/Langenhagen_International_Airport,
2225,EDDW,large_airport,Bremen Airport,53.046786,8.78932,14,EU,DE,DE-HB,Bremen,yes,EDDW,BRE,EDDW,,https://www.bremen-airport.com,https://en.wikipedia.org/wiki/Bremen_Airport,
2227,EDFH,large_airport,Frankfurt-Hahn Airport,49.946353,7.261734,1649,EU,DE,DE-RP,Frankfurt am Main (Lautzenhausen),yes,EDFH,HHN,EDFH,,https://www.hahn-airport.de/en/home,https://en.wikipedia.org/wiki/Frankfurt-Hahn_Airport,
2237,EDJA,large_airport,Memmingen Allgau Airport,47.988092,10.238248,2077,EU,DE,DE-BY,Memmingen,yes,EDJA,FMM,EDJA,,,https://en.wikipedia.org/wiki/Memmingen_Airport,ETSM
2244,EDLP,large_airport,Paderborn Lippstadt Airport,51.612527,8.617459,699,EU,DE,DE-NW,Büren,yes,EDLP,PAD,EDLP,,,https://en.wikipedia.org/wiki/Paderborn_Lippstadt_Airport,
2247,EDLW,large_airport,Dortmund Airport,51.518299,7.61224,425,EU,DE,DE-NW,Dortmund,yes,EDLW,DTM,EDLW,,https://www.dortmund-airport.com/,https://en.wikipedia.org/wiki/Dortmund_Airport,
2256,EDNY,large_airport,Bodensee Airport Friedrichshafen,47.671299,9.51149,1367,EU,DE,DE-BW,Friedrichshafen,yes,EDNY,FDH,EDNY,,https://www.bodensee-airport.eu,https://en.wikipedia.org/wiki/Friedrichshafen_Airport,Bodensee Airport Friedrichshafen
2270,EDSB,large_airport,Karlsruhe Baden-Baden Airport,48.7794,8.0805,408,EU,DE,DE-BW,Rheinmünster,yes,EDSB,FKB,EDSB,,http://www.baden-airpark.de/startseite.html,https://en.wikipedia.org/wiki/Baden_Airpark,Baden Airpark
2285,EDVK,large_airport,Kassel Airport,51.418395,9.391638,820,EU,DE,DE-HE,Calden,yes,EDVK,KSF,EDVK,,http://www.flughafenkassel.de/,https://en.wikipedia.org/wiki/Kassel_Airport,Kassel-Calden
2301,EETN,large_airport,Lennart Meri Tallinn Airport,59.413246,24.83264,131,EU,EE,EE-37,Tallinn,yes,EETN,TLL,EETN,,https://airport.ee/,https://en.wikipedia.org/wiki/Tallinn_Airport,Tallinna Lennujaam
2307,EFHK,large_airport,Helsinki Vantaa Airport,60.318363,24.963341,179,EU,FI,FI-18,Helsinki (Vantaa),yes,EFHK,HEL,EFHK,,https://www.finavia.fi/en/airports/helsinki-airport,https://en.wikipedia.org/wiki/Helsinki_Airport,
2314,EFIV,large_airport,Ivalo Airport,68.6073,27.4053,481,EU,FI,FI-10,Ivalo,yes,EFIV,IVL,EFIV,,http://www.finavia.fi/en/ivalo/,https://en.wikipedia.org/wiki/Ivalo_Airport,
2325,EFKT,large_airport,Kittilä International Airport,67.700996,24.8468,644,EU,FI,FI-10,Kittilä,yes,EFKT,KTT,EFKT,,http://www.finavia.fi/en/kittila/,https://en.wikipedia.org/wiki/Kittil%C3%A4_Airport,
2326,EFKU,large_airport,Kuopio Airport,63.007099,27.7978,323,EU,FI,FI-15,Kuopio / Siilinjärvi,yes,EFKU,KUO,EFKU,,https://www.finavia.fi/en/kuopio/,https://en.wikipedia.org/wiki/Kuopio_Airport,
2329,EFLP,large_airport,Lappeenranta Airport,61.044601,28.144743,349,EU,FI,FI-02,Lappeenranta,yes,EFLP,LPP,EFLP,,https://lppairport.fi/en,https://en.wikipedia.org/wiki/Lappeenranta_Airport,
2334,EFOU,large_airport,Oulu Airport,64.930099,25.354601,47,EU,FI,FI-14,Oulu / Oulunsalo,yes,EFOU,OUL,EFOU,,https://www.finavia.fi/en/oulu/,https://en.wikipedia.org/wiki/Oulu_Airport,
2341,EFRO,large_airport,Rovaniemi Airport,66.563327,25.829751,642,EU,FI,FI-10,Rovaniemi,yes,EFRO,RVN,EFRO,,http://www.finavia.fi/en/rovaniemi/,https://en.wikipedia.org/wiki/Rovaniemi_Airport,
2347,EFTP,large_airport,Tampere-Pirkkala Airport,61.414101,23.604401,390,EU,FI,FI-11,Tampere / Pirkkala,yes,EFTP,TMP,EFTP,,https://www.finavia.fi/en/tampere-pirkkala/,https://en.wikipedia.org/wiki/Tampere-Pirkkala_Airport,
2349,EFTU,large_airport,Turku Airport,60.514099,22.2628,161,EU,FI,FI-19,Turku,yes,EFTU,TKU,EFTU,,https://www.finavia.fi/en/turku/,https://en.wikipedia.org/wiki/Turku_Airport,
2351,EFVA,large_airport,Vaasa Airport,63.05023,21.762543,19,EU,FI,FI-12,Vaasa,yes,EFVA,VAA,EFVA,,http://www.finavia.fi/en/vaasa/,https://en.wikipedia.org/wiki/Vaasa_Airport,
2385,EGAA,large_airport,Belfast International Airport,54.6575012207,-6.2158298492399995,268,EU,GB,GB-NIR,Belfast,yes,EGAA,BFS,EGAA,,http://www.belfastairport.com/,https://en.wikipedia.org/wiki/Belfast_International_Airport,"Aldergrove, RAF Aldergrove"
2389,EGBB,large_airport,Birmingham Airport,52.453899,-1.74803,327,EU,GB,GB-ENG,"Birmingham, West Midlands",yes,EGBB,BHX,EGBB,,https://www.birminghamairport.co.uk/,https://en.wikipedia.org/wiki/Birmingham_Airport,Birmingham International Airport
2398,EGCC,large_airport,Manchester Airport,53.349375,-2.279521,257,EU,GB,GB-ENG,"Manchester, Greater Manchester",yes,EGCC,MAN,EGCC,,http://www.manchesterairport.co.uk/,https://en.wikipedia.org/wiki/Manchester_Airport,"Ringway Airport, RAF Ringway"
2415,EGFF,large_airport,Cardiff International Airport,51.396702,-3.34333,220,EU,GB,GB-WLS,Cardiff,yes,EGFF,CWL,EGFF,,http://www.cwlfly.com/,https://en.wikipedia.org/wiki/Cardiff_Airport,"Caerdydd, Cardiff International Airport, Maes Awyr Caerdydd, Rhoose, Y Rhws"
2417,EGGD,large_airport,Bristol Airport,51.382326,-2.716453,622,EU,GB,GB-ENG,Bristol,yes,EGGD,BRS,EGGD,,http://www.bristolairport.co.uk/,https://en.wikipedia.org/wiki/Bristol_Airport,Lulsgate Bottom
2418,EGGP,large_airport,Liverpool John Lennon Airport,53.334863,-2.849637,80,EU,GB,GB-ENG,Liverpool,yes,EGGP,LPL,EGGP,,http://www.liverpoolairport.com/,https://en.wikipedia.org/wiki/Liverpool_John_Lennon_Airport,
2434,EGLL,large_airport,London Heathrow Airport,51.470748,-0.459909,83,EU,GB,GB-ENG,London,yes,EGLL,LHR,EGLL,,http://www.heathrow.com/,https://en.wikipedia.org/wiki/Heathrow_Airport,"LON, Londres"
2444,EGNM,large_airport,Leeds Bradford Airport,53.865898,-1.66057,681,EU,GB,GB-ENG,"Leeds, West Yorkshire",yes,EGNM,LBA,EGNM,,http://www.lbia.co.uk/,https://en.wikipedia.org/wiki/Leeds_Bradford_International_Airport,
2447,EGNS,large_airport,Isle of Man Airport,54.083081,-4.623871,52,EU,IM,IM-U-A,Castletown,yes,EGNS,IOM,EGNS,,http://www.iom-airport.com/,https://en.wikipedia.org/wiki/Isle_of_Man_Airport,"Ronaldsway, Douglas"
2448,EGNT,large_airport,Newcastle International Airport,55.037958,-1.689577,266,EU,GB,GB-ENG,"Newcastle upon Tyne, Tyne and Wear",yes,EGNT,NCL,EGNT,,http://www.newcastleairport.com/,https://en.wikipedia.org/wiki/Newcastle_International_Airport,
2450,EGNX,large_airport,East Midlands Airport,52.8311,-1.32806,306,EU,GB,GB-ENG,"Nottingham, Leicestershire",yes,EGNX,EMA,EGNX,,http://www.eastmidlandsairport.com/,https://en.wikipedia.org/wiki/East_Midlands_Airport,RAF Castle Donington
2459,EGPD,large_airport,Aberdeen International Airport,57.2019,-2.19778,215,EU,GB,GB-SCT,Aberdeen,yes,EGPD,ABZ,EGPD,,http://www.aberdeenairport.com/,https://en.wikipedia.org/wiki/Aberdeen_Airport,Dyce
2461,EGPF,large_airport,Glasgow Airport,55.871899,-4.43306,26,EU,GB,GB-SCT,Glasgow,yes,EGPF,GLA,EGPF,,http://www.glasgowairport.com/,https://en.wikipedia.org/wiki/Glasgow_International_Airport,"Port-adhair Eadar-nàiseanta Ghlaschu, RAF Abbotsinch, RAF Ayr"
2462,EGPH,large_airport,Edinburgh Airport,55.950145,-3.372288,135,EU,GB,GB-SCT,"Ingliston, Edinburgh",yes,EGPH,EDI,EGPH,,http://www.edinburghairport.com/,https://en.wikipedia.org/wiki/Edinburgh_Airport,
2513,EHAM,large_airport,Amsterdam Airport Schiphol,52.308601,4.76389,-11,EU,NL,NL-NH,Amsterdam,yes,EHAM,AMS,EHAM,,https://www.schiphol.nl/,https://en.wikipedia.org/wiki/Amsterdam_Airport_Schiphol,
2515,EHBK,large_airport,Maastricht Aachen Airport,50.911087,5.769401,375,EU,NL,NL-LI,Maastricht,yes,EHBK,MST,EHBK,,,https://en.wikipedia.org/wiki/Maastricht_Aachen_Airport,
2518,EHEH,large_airport,Eindhoven Airport,51.4500999451,5.37452983856,74,EU,NL,NL-NB,Eindhoven,yes,EHEH,EIN,EHEH,,http://www.eindhovenairport.nl/,https://en.wikipedia.org/wiki/Eindhoven_Airport,Welschap Airport
2519,EHGG,large_airport,Groningen Airport Eelde,53.119107,6.577652,17,EU,NL,NL-DR,Groningen,yes,EHGG,GRQ,EHGG,,,https://en.wikipedia.org/wiki/Groningen_Airport_Eelde,
2524,EHRD,large_airport,Rotterdam The Hague Airport,51.956902,4.43722,-15,EU,NL,NL-ZH,Rotterdam,yes,EHRD,RTM,EHRD,,https://www.rotterdamthehagueairport.nl/,https://en.wikipedia.org/wiki/Rotterdam_The_Hague_Airport,Vliegveld Zestienhoven
2530,EICK,large_airport,Cork International Airport,51.841301,-8.49111,502,EU,IE,IE-CO,Cork,yes,EICK,ORK,EICK,,http://www.corkairport.com/,https://en.wikipedia.org/wiki/Cork_Airport,Aerfort Chorcaí
2533,EIDW,large_airport,Dublin Airport,53.428713,-6.262121,242,EU,IE,IE-D,Dublin,yes,EIDW,DUB,EIDW,,http://www.dublinairport.com/,https://en.wikipedia.org/wiki/Dublin_Airport,Aerfort Bhaile Átha Cliath
2534,EIKN,large_airport,Ireland West Airport Knock,53.910439,-8.817002,665,EU,IE,IE-MO,Charlestown,yes,EIKN,NOC,EIKN,,http://www.irelandwestairport.com/,https://en.wikipedia.org/wiki/Ireland_West_Airport_Knock,"Connaught, Horan International Airport"
2537,EINN,large_airport,Shannon Airport,52.702,-8.92482,46,EU,IE,IE-CE,Shannon,yes,EINN,SNN,EINN,,http://www.shannonairport.com/,https://en.wikipedia.org/wiki/Shannon_Airport,
2540,EKAH,large_airport,Aarhus Airport,56.303331,10.618286,82,EU,DK,DK-82,Aarhus,yes,EKAH,AAR,EKAH,,http://www.aar.dk/,https://en.wikipedia.org/wiki/Aarhus_Airport,Tirstrup
2541,EKBI,large_airport,Billund Airport,55.740335,9.157019,247,EU,DK,DK-83,Billund,yes,EKBI,BLL,EKBI,,http://www.billund-airport.dk/?sc_lang=en,https://en.wikipedia.org/wiki/Billund_Airport,
2542,EKCH,large_airport,Copenhagen Kastrup Airport,55.617900848389,12.656000137329,17,EU,DK,DK-84,Copenhagen,yes,EKCH,CPH,EKCH,,http://www.cph.dk/en/,https://en.wikipedia.org/wiki/Copenhagen_Airport,"København, Malmö"
2549,EKOD,large_airport,Odense Hans Christian Andersen Airport,55.475307,10.327206,56,EU,DK,DK-83,Odense,yes,EKOD,ODE,EKOD,,,https://en.wikipedia.org/wiki/Hans_Christian_Andersen_Airport,Beldringe
2559,EKVG,large_airport,Vágar Airport,62.063256,-7.275782,280,EU,FO,FO-U-A,Vágar,yes,EKVG,FAE,EKVG,,https://www.fae.fo/,https://en.wikipedia.org/wiki/V%C3%A1gar_Airport,"Faroes, RAF Vágar"
2562,EKYT,large_airport,Aalborg Airport,57.094763,9.84993,10,EU,DK,DK-81,Aalborg,yes,EKYT,AAL,EKYT,,http://www.aal.dk,https://en.wikipedia.org/wiki/Aalborg_Airport,
2563,ELLX,large_airport,Luxembourg-Findel International Airport,49.626845,6.212134,1234,EU,LU,LU-L,Luxembourg,yes,ELLX,LUX,ELLX,,https://www.lux-airport.lu/en/,https://en.wikipedia.org/wiki/Luxembourg-Findel_International_Airport,
2564,ENAL,large_airport,Ålesund Airport,62.560443,6.110845,69,EU,NO,NO-15,Ålesund,yes,ENAL,AES,ENAL,,https://www.avinor.no/airport/alesund/,https://en.wikipedia.org/wiki/%C3%85lesund_Airport%2C_Vigra,"Vigra, Ålesund Vigra Airport"
2569,ENBO,large_airport,Bodø Airport,67.269203,14.3653,42,EU,NO,NO-18,Bodø,yes,ENBO,BOO,ENBO,,http://www.avinor.no/en/airport/bodo/,https://en.wikipedia.org/wiki/Bod%C3%B8_Airport,"Bodø Air Force Base, Bodø Main Air Station"
2570,ENBR,large_airport,"Bergen Airport, Flesland",60.2934,5.21814,170,EU,NO,NO-46,Bergen,yes,ENBR,BGO,ENBR,,http://www.avinor.no/en/airport/bergen,https://en.wikipedia.org/wiki/Bergen_Airport%2C_Flesland,
2572,ENCN,large_airport,Kristiansand Airport,58.204201,8.08537,57,EU,NO,NO-42,Kristiansand(Kjevik),yes,ENCN,KRS,ENCN,,http://www.avinor.no/en/airport/kristiansand,https://en.wikipedia.org/wiki/Kristiansand_Airport%2C_Kjevik,Kjevik Airport
2575,ENEV,large_airport,Harstad/Narvik Airport,68.491302,16.678101,84,EU,NO,NO-18,Evenes,yes,ENEV,EVE,ENEV,,https://avinor.no/en/airport/harstadnarvik-airport/,https://en.wikipedia.org/wiki/Harstad/Narvik_Airport%2C_Evenes,"Harstad/Narvik lufthavn, Evenes, Evenes Airport"
2578,ENGM,large_airport,Oslo-Gardermoen International Airport,60.193901,11.1004,681,EU,NO,NO-32,Oslo (Gardermoen),yes,ENGM,OSL,ENGM,,https://avinor.no/en/airport/oslo-airport,"https://en.wikipedia.org/wiki/Oslo_Airport,_Gardermoen",
2599,ENTC,large_airport,Tromsø Airport,69.683296,18.9189,31,EU,NO,NO-55,Tromsø,yes,ENTC,TOS,ENTC,,http://www.avinor.no/en/airport/tromso,https://en.wikipedia.org/wiki/Troms%C3%B8_Airport,"Langnes, Tromsøya, Tromso"
2600,ENTO,large_airport,"Sandefjord Airport, Torp",59.186699,10.2586,286,EU,NO,NO-39,Sandefjord(Torp),yes,ENTO,TRF,ENTO,,https://torp.no/,https://en.wikipedia.org/wiki/Sandefjord_Airport%2C_Torp,Torp
2601,ENVA,large_airport,"Trondheim Airport, Værnes",63.457802,10.924,56,EU,NO,NO-50,Trondheim,yes,ENVA,TRD,ENVA,,https://avinor.no/en/airport/trondheim-airport/,https://en.wikipedia.org/wiki/Trondheim_Airport%2C_V%C3%A6rnes,
2602,ENZV,large_airport,"Stavanger Airport, Sola",58.876701,5.63778,29,EU,NO,NO-11,Stavanger,yes,ENZV,SVG,ENZV,,http://www.avinor.no/en/airport/stavanger/,"https://en.wikipedia.org/wiki/Stavanger_Airport,_Sola","Sola Air Station, Stavanger lufthavn, Sola"
2608,EPGD,large_airport,Gdańsk Lech Wałęsa Airport,54.377602,18.4662,489,EU,PL,PL-PM,Gdańsk,yes,EPGD,GDN,EPGD,,http://www.airport.gdansk.pl/,https://en.wikipedia.org/wiki/Gda%C5%84sk_Lech_Wa%C5%82%C4%99sa_Airport,Gdańsk-Rębiechowo
2609,EPKK,large_airport,Kraków John Paul II International Airport,50.077702,19.7848,791,EU,PL,PL-MA,Balice,yes,EPKK,KRK,EPKK,,http://www.krakowairport.pl/en/,https://en.wikipedia.org/wiki/Kraków_John_Paul_II_International_Airport,Krakov
2613,EPKT,large_airport,Katowice Wojciech Korfanty International Airport,50.476015,19.080705,995,EU,PL,PL-SL,Katowice,yes,EPKT,KTW,EPKT,,http://katowice.airport.com,https://en.wikipedia.org/wiki/Katowice_International_Airport,"Pyrzowice, Dąbrowa Górnicza, Katowice, Silesia, GOP, Silesia Metropolis, Gliwice, Piekary Śląskie, Bytom, Zabrze, Sosnowiec"
309484,EPLB,large_airport,Lublin Airport,51.240157,22.713461,633,EU,PL,PL-LU,Lublin,yes,EPLB,LUZ,EPLB,,,https://en.wikipedia.org/wiki/Lublin_Airport,
2616,EPLL,large_airport,Łódź Władysław Reymont Airport,51.721901,19.3981,604,EU,PL,PL-LD,Łódź,yes,EPLL,LCJ,EPLL,,https://www.lodz-airport.pl/en,https://en.wikipedia.org/wiki/%C5%81%C3%B3d%C5%BA_W%C5%82adys%C5%82aw_Reymont_Airport,Lublinek
2625,EPPO,large_airport,Poznań-Ławica Airport,52.421598,16.823359,308,EU,PL,PL-WP,Poznań,yes,EPPO,POZ,EPPO,,https://poznanairport.pl,https://en.wikipedia.org/wiki/Pozna%C5%84-%C5%81awica_Airport,Poznań–Ławica Henryk Wieniawski Airport
2629,EPRZ,large_airport,Rzeszów-Jasionka Airport,50.109791,22.024155,693,EU,PL,PL-PK,Jasionka,yes,EPRZ,RZE,EPRZ,,https://www.rzeszowairport.pl,https://en.wikipedia.org/wiki/Rzesz%C3%B3w-Jasionka_Airport,
2630,EPSC,large_airport,Solidarity Szczecin–Goleniów Airport,53.584702,14.9022,154,EU,PL,PL-ZP,Szczecin(Glewice),yes,EPSC,SZZ,EPSC,,http://www.airport.com.pl/en/index.php,https://en.wikipedia.org/wiki/Szczecin-Goleni%C3%B3w_%22Solidarno%C5%9B%C4%87%22_Airport,"Solidarność, Glewice, Port Lotniczy Szczecin–Goleniów im. NSZZ Solidarność"
2637,EPWA,large_airport,Warsaw Chopin Airport,52.165699,20.9671,362,EU,PL,PL-MZ,Warsaw,yes,EPWA,WAW,EPWA,,http://www.lotnisko-chopina.pl/?lang=en,https://en.wikipedia.org/wiki/Warsaw_Frederic_Chopin_Airport,"Okęcie, Warszawa"
2638,EPWR,large_airport,Copernicus Wrocław Airport,51.103719,16.882096,404,EU,PL,PL-DS,Wrocław,yes,EPWR,WRO,EPWR,,http://www.airport.wroclaw.pl/en/index.php,https://en.wikipedia.org/wiki/Copernicus_Airport_Wroc%C5%82aw,"Strachowice, Breslau, Wroclaw"
2648,ESGG,large_airport,Göteborg Landvetter Airport,57.6628,12.2798,506,EU,SE,SE-Q,Göteborg,yes,ESGG,GOT,ESGG,,https://www.swedavia.com/landvetter/,https://en.wikipedia.org/wiki/G%C3%B6teborg_Landvetter_Airport,"Göteborg-Landvetter, Gothenburg"
2660,ESKN,large_airport,Stockholm Skavsta Airport,58.789656,16.911479,140,EU,SE,SE-D,Nyköping,yes,ESKN,NYO,ESKN,,,https://en.wikipedia.org/wiki/Stockholm-Skavsta_Airport,
316498,ESKS,large_airport,Scandinavian Mountains Airport,61.16508,12.83352,1608,EU,SE,SE-W,Malung-Sälen,yes,ESKS,SCR,ESKS,,https://scandinavianmountains.se/en/home/,https://en.wikipedia.org/wiki/Scandinavian_Mountains_Airport,"Sälens Flygplats, Trysil, Sälen Trysil Airport, Sälen/Scandinavian Mountains Airport"
2669,ESMS,large_airport,Malmö Sturup Airport,55.535564,13.376327,236,EU,SE,SE-M,Malmö,yes,ESMS,MMX,ESMS,,http://www.swedavia.com/malmo/,https://en.wikipedia.org/wiki/Malm%C3%B6_Airport,
2685,ESNQ,large_airport,Kiruna Airport,67.821999,20.3368,1508,EU,SE,SE-BD,Kiruna,yes,ESNQ,KRN,ESNQ,,https://www.swedavia.com/kiruna/,https://en.wikipedia.org/wiki/Kiruna_Airport,
2689,ESNU,large_airport,Umeå Airport,63.791801,20.282801,24,EU,SE,SE-AC,Umeå,yes,ESNU,UME,ESNU,,https://www.swedavia.com/umea/,https://en.wikipedia.org/wiki/Ume%C3%A5_Airport,
2696,ESOW,large_airport,Stockholm Västerås Airport,59.589401,16.6336,21,EU,SE,SE-U,Stockholm / Västerås,yes,ESOW,VST,ESOW,,,https://en.wikipedia.org/wiki/Stockholm-V%C3%A4ster%C3%A5s_Airport,
2697,ESPA,large_airport,Luleå Airport,65.5438,22.122,65,EU,SE,SE-BD,Luleå,yes,ESPA,LLA,ESPA,,https://www.swedavia.com/lulea/,https://en.wikipedia.org/wiki/Lule%C3%A5_Airport,Kallax Air Base
2701,ESSA,large_airport,Stockholm-Arlanda Airport,59.64849,17.928829,137,EU,SE,SE-AB,Stockholm,yes,ESSA,ARN,ESSA,,http://www.swedavia.se/arlanda/,https://en.wikipedia.org/wiki/Stockholm-Arlanda_Airport,
2706,ESSL,large_airport,Linköping City Airport,58.404909,15.684499,172,EU,SE,SE-E,Linköping,yes,ESSL,LPI,ESSL,,http://www.linkopingsflygplats.se/,https://en.wikipedia.org/wiki/Link%C3%B6ping_SAAB_Airport,
2710,ESSV,large_airport,Visby Airport,57.6628,18.346201,164,EU,SE,SE-I,Visby,yes,ESSV,VBY,ESSV,,https://www.swedavia.com/visby/,https://en.wikipedia.org/wiki/Visby_Airport,
2757,EVLA,large_airport,Liepāja International Airport,56.517502,21.096901,16,EU,LV,LV-112,Liepāja,no,EVLA,LPX,EVLA,,http://www.liepaja-airport.lv/,https://en.wikipedia.org/wiki/Liep%C4%81ja_International_Airport,
2758,EVRA,large_airport,Riga International Airport,56.920752,23.970711,36,EU,LV,LV-062,Riga,yes,EVRA,RIX,EVRA,,http://www.riga-airport.com/,https://en.wikipedia.org/wiki/Riga_International_Airport,
2760,EYKA,large_airport,Kaunas International Airport,54.963965,24.08582,256,EU,LT,LT-KU,Kaunas,yes,EYKA,KUN,EYKA,,http://www.kaunasair.lt/index.php?lang=2&m=2&p=210,https://en.wikipedia.org/wiki/Kaunas_Airport,Kauno tarptautinis Oro Uostas
2762,EYPA,large_airport,Palanga International Airport,55.973202,21.093901,33,EU,LT,LT-KL,Palanga,yes,EYPA,PLQ,EYPA,,http://www.palanga-airport.lt/,https://en.wikipedia.org/wiki/Palanga_International_Airport,
2766,EYVI,large_airport,Vilnius International Airport,54.634102,25.285801,648,EU,LT,LT-VL,Vilnius,yes,EYVI,VNO,EYVI,,http://www.vilnius-airport.lt/en/,https://en.wikipedia.org/wiki/Vilnius_International_Airport,
3972,LATI,large_airport,Tirana International Airport Mother Teresa,41.4147,19.7206,126,EU,AL,AL-02,Rinas,yes,LATI,TIA,LATI,,http://www.tirana-airport.com/,https://en.wikipedia.org/wiki/Tirana_International_Airport_N%C3%ABn%C3%AB_Tereza,"Rinas International Airport, Aeroporti Nënë Tereza"
3974,LBBG,large_airport,Burgas Airport,42.569917,27.515173,135,EU,BG,BG-02,Burgas,yes,LBBG,BOJ,LBBG,,,https://en.wikipedia.org/wiki/Burgas_Airport,Bourgas
3976,LBPD,large_airport,Plovdiv International Airport,42.067799,24.8508,597,EU,BG,BG-16,Plovdiv,yes,LBPD,PDV,LBPD,,http://www.plovdivairport.com/,https://en.wikipedia.org/wiki/Plovdiv_International_Airport,
3977,LBSF,large_airport,Sofia Airport,42.696357,23.417671,1742,EU,BG,BG-22,Sofia,yes,LBSF,SOF,LBSF,,http://sofia-airport.bg,https://en.wikipedia.org/wiki/Sofia_Airport,"Sofia International Airport, Vrazhdebna Air Base"
3979,LBWN,large_airport,Varna Airport,43.232101,27.8251,230,EU,BG,BG-03,Varna,yes,LBWN,VAR,LBWN,,,https://en.wikipedia.org/wiki/Varna_Airport,Aksakovo
3984,LDDU,large_airport,Dubrovnik Ruđer Bošković Airport,42.562247,18.265543,527,EU,HR,HR-19,Dubrovnik,yes,LDDU,DBV,LDDU,,https://www.airport-dubrovnik.hr/en,https://en.wikipedia.org/wiki/Dubrovnik_Airport,"Močići Airport, Zračna luka Ruđer Bošković Dubrovnik"
3987,LDPL,large_airport,Pula Airport,44.893501,13.9222,274,EU,HR,HR-18,Pula,yes,LDPL,PUY,LDPL,,https://airport-pula.hr/en/,https://en.wikipedia.org/wiki/Pula_Airport,
3989,LDRI,large_airport,Rijeka Airport,45.216376,14.57085,278,EU,HR,HR-08,Rijeka(Omišalj),yes,LDRI,RJK,LDRI,,https://www.rijeka-airport.hr/,https://en.wikipedia.org/wiki/Rijeka_Airport,"Reka, Rika, Fiume, Omišalj"
3991,LDSP,large_airport,Split Saint Jerome Airport,43.538898,16.298,79,EU,HR,HR-17,Split,yes,LDSP,SPU,LDSP,,https://split-airport.hr,https://en.wikipedia.org/wiki/Split_Airport,
3993,LDZA,large_airport,Zagreb Franjo Tuđman International Airport,45.742901,16.0688,353,EU,HR,HR-01,Velika Gorica,yes,LDZA,ZAG,LDZA,,https://www.zagreb-airport.hr/en,https://en.wikipedia.org/wiki/Zagreb_Airport,Pleso
3994,LDZD,large_airport,Zadar Airport,44.096986,15.353565,289,EU,HR,HR-13,Zadar,yes,LDZD,ZAD,LDZD,,https://www.zadar-airport.hr/en,https://en.wikipedia.org/wiki/Zadar_Airport,
3997,LEAL,large_airport,Alicante-Elche Miguel Hernández Airport,38.2822,-0.558156,142,EU,ES,ES-VC,Alicante,yes,LEAL,ALC,LEAL,,https://www.aena.es/en/alicante-elche.html,https://en.wikipedia.org/wiki/Alicante–Elche_Miguel_Hernández_Airport,
3999,LEAS,large_airport,Asturias Airport,43.563599,-6.03462,416,EU,ES,ES-AS,Ranón,yes,LEAS,OVD,LEAS,,https://www.aena.es/en/asturias.html,https://en.wikipedia.org/wiki/Asturias_Airport,
4001,LEBB,large_airport,Bilbao Airport,43.301102,-2.91061,138,EU,ES,ES-PV,Bilbao,yes,LEBB,BIO,LEBB,,https://www.aena.es/en/bilbao.html,https://en.wikipedia.org/wiki/Bilbao_Airport,
4004,LEBL,large_airport,Josep Tarradellas Barcelona-El Prat Airport,41.2971,2.07846,12,EU,ES,ES-CT,Barcelona,yes,LEBL,BCN,LEBL,,https://www.aena.es/en/josep-tarradellas-barcelona-el-prat.html,https://en.wikipedia.org/wiki/Barcelona%E2%80%93El_Prat_Airport,"El Prat Airport,Barcelona International,Josep Tarradellas Barcelona-El Prat Airport"
4010,LEGE,large_airport,Girona-Costa Brava Airport,41.904639,2.761774,468,EU,ES,ES-CT,Girona,yes,LEGE,GRO,LEGE,,https://www.aena.es/en/girona-costa-brava.html,https://en.wikipedia.org/wiki/Girona-Costa_Brava_Airport,"Girona, Costa Brava, Girona-Costa Brava"
4013,LEIB,large_airport,Ibiza Airport,38.872898,1.37312,24,EU,ES,ES-IB,Ibiza (Eivissa),yes,LEIB,IBZ,LEIB,,https://www.aena.es/en/ibiza.html,https://en.wikipedia.org/wiki/Ibiza_Airport,
4019,LEMD,large_airport,Adolfo Suárez Madrid–Barajas Airport,40.493407,-3.572249,1998,EU,ES,ES-MD,Madrid,yes,LEMD,MAD,LEMD,,https://www.aena.es/en/adolfo-suarez-madrid-barajas.html,https://en.wikipedia.org/wiki/Adolfo_Su%C3%A1rez_Madrid%E2%80%93Barajas_Airport,"Leganés, Madrid Barajas International Airport"
4020,LEMG,large_airport,Málaga-Costa del Sol Airport,36.6749,-4.49911,53,EU,ES,ES-AN,Málaga,yes,LEMG,AGP,LEMG,,https://www.aena.es/en/malaga-costa-del-sol.html,https://en.wikipedia.org/wiki/M%C3%A1laga_Airport,"Costa del Sol, Malaga"
4021,LEMH,large_airport,Menorca Airport,39.862598,4.21865,302,EU,ES,ES-IB,Mahón (Maó),yes,LEMH,MAH,LEMH,,https://www.aena.es/en/menorca.html,https://en.wikipedia.org/wiki/Menorca_Airport,Menorca
308134,LEMI,large_airport,Region of Murcia International Airport,37.802852,-1.124884,644,EU,ES,ES-MC,Corvera,yes,LEMI,RMU,LEMI,,https://www.aena.es/en/internacional-region-de-murcia.html,https://en.wikipedia.org/wiki/Murcia-Corvera_Airport,
4035,LEPA,large_airport,Palma de Mallorca Airport,39.551701,2.73881,27,EU,ES,ES-IB,Palma de Mallorca,yes,LEPA,PMI,LEPA,,https://www.aena.es/en/palma-de-mallorca.html,https://en.wikipedia.org/wiki/Son_Sant_Joan_Airport,"Son Sant Joan Airport, LESJ"
4031,LERS,large_airport,Reus Airport,41.147509,1.168354,233,EU,ES,ES-CT,Reus,yes,LERS,REU,LERS,,https://www.aena.es/en/reus.html,https://en.wikipedia.org/wiki/Reus_Airport,
4038,LEST,large_airport,Santiago-Rosalía de Castro Airport,42.896301,-8.41514,1213,EU,ES,ES-GA,Santiago de Compostela,yes,LEST,SCQ,LEST,,https://www.aena.es/en/santiago-rosalia-de-castro.html,https://en.wikipedia.org/wiki/Santiago_de_Compostela_Airport,
4042,LEVC,large_airport,Valencia Airport,39.489162,-0.480961,240,EU,ES,ES-VC,Valencia,yes,LEVC,VLC,LEVC,,https://www.aena.es/en/valencia.html,https://en.wikipedia.org/wiki/Valencia_Airport,
4048,LEZG,large_airport,Zaragoza Airport,41.666199,-1.04155,863,EU,ES,ES-AR,Zaragoza,yes,LEZG,ZAZ,LEZG,,https://www.aena.es/en/zaragoza.html,https://en.wikipedia.org/wiki/Zaragoza_Airport,Zaragoza Air Base
4049,LEZL,large_airport,Seville Airport,37.417999,-5.89311,112,EU,ES,ES-AN,Seville,yes,LEZL,SVQ,LEZL,,https://www.aena.es/en/seville.html,https://en.wikipedia.org/wiki/Seville_Airport,"Sevilla, Sevilla Airport"
4060,LFBD,large_airport,Bordeaux–Mérignac Airport,44.82865,-0.715356,162,EU,FR,FR-NAQ,Bordeaux,yes,LFBD,BOD,LFBD,,https://www.bordeaux.aeroport.fr/,https://en.wikipedia.org/wiki/Bordeaux%E2%80%93M%C3%A9rignac_Airport,
4070,LFBO,large_airport,Toulouse-Blagnac Airport,43.629101,1.36382,499,EU,FR,FR-OCC,Toulouse/Blagnac,yes,LFBO,TLS,LFBO,,http://www.toulouse.aeroport.fr/,https://en.wikipedia.org/wiki/Toulouse_Blagnac_International_Airport,Airbus
4123,LFKB,large_airport,Bastia-Poretta International airport,42.5527,9.48373,26,EU,FR,FR-COR,Bastia,yes,LFKB,BIA,LFKB,,https://bastia-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Bastia_-_Poretta_Airport,
4125,LFKF,large_airport,Figari Sud-Corse Airport,41.50185,9.097092,85,EU,FR,FR-COR,Figari,yes,LFKF,FSC,LFKF,,https://figari-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Figari_Sud-Corse_Airport,
4132,LFLC,large_airport,Clermont-Ferrand Auvergne airport,45.786701,3.16917,1090,EU,FR,FR-ARA,Clermont-Ferrand,yes,LFLC,CFE,LFLC,,https://www.clermont-aeroport.com/en,https://en.wikipedia.org/wiki/Clermont-Ferrand_Auvergne_Airport,
4137,LFLL,large_airport,Lyon Saint-Exupéry Airport,45.725996,5.090139,821,EU,FR,FR-ARA,"Colombier-Saugnieu, Rhône",yes,LFLL,LYS,LFLL,,https://www.lyonaeroports.com/,https://en.wikipedia.org/wiki/Lyon%E2%80%93Saint-Exup%C3%A9ry_Airport,"Lyon, Saint-Exupéry, Satolas"
4155,LFML,large_airport,Marseille Provence Airport,43.438088,5.2125,74,EU,FR,FR-PAC,"Marignane, Bouches-du-Rhône",yes,LFML,MRS,LFML,,https://www.marseille.aeroport.fr/,https://en.wikipedia.org/wiki/Marseille_Provence_Airport,
4156,LFMN,large_airport,Nice-Côte d'Azur Airport,43.658401,7.21587,12,EU,FR,FR-PAC,"Nice, Alpes-Maritimes",yes,LFMN,NCE,LFMN,,https://www.nice.aeroport.fr/en,https://en.wikipedia.org/wiki/C%C3%B4te_d'Azur_International_Airport,
4161,LFMT,large_airport,Montpellier-Méditerranée Airport,43.576199,3.96301,17,EU,FR,FR-OCC,Montpellier/Méditerranée,yes,LFMT,MPL,LFMT,,https://www.montpellier.aeroport.fr/,https://en.wikipedia.org/wiki/Montpellier_-_M%C3%A9diterran%C3%A9e_Airport,
4169,LFOB,large_airport,Beauvais-Tillé airport,49.454399,2.11278,359,EU,FR,FR-HDF,Beauvais,yes,LFOB,BVA,LFOB,,https://www.aeroportparisbeauvais.com/,https://en.wikipedia.org/wiki/Paris_Beauvais_Till%C3%A9_Airport,Beauvais-Tillé
4185,LFPG,large_airport,Charles de Gaulle International Airport,49.00896,2.554117,392,EU,FR,FR-IDF,"Paris (Roissy-en-France, Val-d'Oise)",yes,LFPG,CDG,LFPG,,http://www.aeroportsdeparis.fr/,https://en.wikipedia.org/wiki/Charles_de_Gaulle_Airport,"PAR, Aéroport Roissy-Charles de Gaulle, Roissy Airport"
4202,LFQQ,large_airport,Lille Airport,50.566564,3.102429,157,EU,FR,FR-HDF,Lesquin,yes,LFQQ,LIL,LFQQ,,https://www.lille.aeroport.fr/home/,https://en.wikipedia.org/wiki/Lille_Airport,Aéroport de Lille. Lille-Lesquin Airport
4206,LFRB,large_airport,Brest Bretagne airport,48.447899,-4.41854,325,EU,FR,FR-BRE,Brest,yes,LFRB,BES,LFRB,,https://www.brest.aeroport.bzh,https://en.wikipedia.org/wiki/Brest_Bretagne_Airport,
4221,LFRS,large_airport,Nantes Atlantique Airport,47.153198,-1.61073,90,EU,FR,FR-PDL,Nantes,yes,LFRS,NTE,LFRS,,https://www.nantes.aeroport.fr/en,https://en.wikipedia.org/wiki/Nantes_Atlantique_Airport,
4226,LFSB,large_airport,EuroAirport Basel–Mulhouse–Freiburg,47.60068,7.521117,885,EU,FR,FR-GES,Bâle / Mulhouse,yes,LFSB,BSL,LFSB,,http://www.euroairport.com/,https://en.wikipedia.org/wiki/EuroAirport_Basel-Mulhouse-Freiburg,"MLH, EAP, LSZM, Bâle, Basle"
4238,LFST,large_airport,Strasbourg Airport,48.5383,7.62823,505,EU,FR,FR-GES,Strasbourg,yes,LFST,SXB,LFST,,http://www.strasbourg.aeroport.fr/E/index.php,https://en.wikipedia.org/wiki/Strasbourg_Airport,BA 124
4251,LGAV,large_airport,Athens Eleftherios Venizelos International Airport,37.936401,23.9445,308,EU,GR,GR-I,Spata-Artemida,yes,LGAV,ATH,LGAV,,https://www.aia.gr,https://en.wikipedia.org/wiki/Athens_International_Airport,
4258,LGIR,large_airport,Heraklion International Nikos Kazantzakis Airport,35.339699,25.1803,115,EU,GR,GR-M,Heraklion,yes,LGIR,HER,LGIR,,https://www.heraklion-airport.gr/en/,"https://en.wikipedia.org/wiki/Heraklion_International_Airport_""Nikos_Kazantzakis""",Crete Island
4266,LGKR,large_airport,Corfu Ioannis Kapodistrias International Airport,39.60145,19.912179,6,EU,GR,GR-F,Kerkyra (Corfu),yes,LGKR,CFU,LGKR,,https://www.cfu-airport.gr/en,https://en.wikipedia.org/wiki/Corfu_International_Airport,Corfu
4268,LGKV,large_airport,Kavala Alexander the Great International Airport,40.9133,24.6192,18,EU,GR,GR-A,Kavala,yes,LGKV,KVA,LGKV,,https://www.kva-airport.gr/en,https://en.wikipedia.org/wiki/Kavala_International_Airport,Megas Alexandros
4281,LGSA,large_airport,Chania International Airport,35.531207,24.150673,490,EU,GR,GR-M,Souda,yes,LGSA,CHQ,LGSA,,,https://en.wikipedia.org/wiki/Chania_International_Airport,
4286,LGSR,large_airport,Santorini International Airport,36.400045,25.478638,127,EU,GR,GR-L,Santorini Island,yes,LGSR,JTR,LGSR,,https://www.jtr-airport.gr/,https://en.wikipedia.org/wiki/Santorini_(Thira)_National_Airport,
4293,LGTS,large_airport,Thessaloniki Macedonia International Airport,40.51928,22.970009,22,EU,GR,GR-B,Thessaloniki,yes,LGTS,SKG,LGTS,,https://www.skg-airport.gr/en,https://en.wikipedia.org/wiki/Thessaloniki_Airport,Makedonia. Macedonia. Salonica
4296,LHBP,large_airport,Budapest Liszt Ferenc International Airport,47.43018,19.262393,495,EU,HU,HU-BU,Budapest,yes,LHBP,BUD,LHBP,,http://www.bud.hu/english,https://en.wikipedia.org/wiki/Budapest_Ferenc_Liszt_International_Airport,"Ferihegyi nemzetközi repülőtér, Budapest Liszt Ferenc international Airport"
4297,LHDC,large_airport,Debrecen International Airport,47.489469,21.616278,359,EU,HU,HU-HB,Debrecen,yes,LHDC,DEB,LHDC,,http://www.airportdebrecen.hu/,https://en.wikipedia.org/wiki/Debrecen_International_Airport,Debreceni nemzetközi repülőtér
4302,LHPP,large_airport,Pécs-Pogány International Airport,45.988891,18.242044,651,EU,HU,HU-BA,Pécs,yes,LHPP,PEV,LHPP,,http://www.airportpecs.hu/,https://en.wikipedia.org/wiki/P%C3%A9cs-Pog%C3%A1ny_International_Airport,"Pecs South Airport, QPJ"
4310,LIBD,large_airport,Bari Karol Wojtyła International Airport,41.138901,16.760599,193,EU,IT,IT-75,Bari,yes,LIBD,BRI,LIBD,BA02,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=25,https://en.wikipedia.org/wiki/Bari_Karol_Wojty%C5%82a_Airport,"Bari ""Karol Wojtyla"" International Airport, Palese Macchie Airport, Palese Airport"
4314,LIBP,large_airport,Abruzzo Airport,42.431079,14.182981,48,EU,IT,IT-65,Pescara,yes,LIBP,PSR,LIBP,PE01,http://www.abruzzo-airport.it/eng/index.php,https://en.wikipedia.org/wiki/Abruzzo_Airport,"Pescara, Abruzzo Pasquale Liberi International"
4315,LIBR,large_airport,Brindisi Airport,40.6576,17.947001,47,EU,IT,IT-75,Brindisi,yes,LIBR,BDS,LIBR,BR03,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=29,https://en.wikipedia.org/wiki/Brindisi_Airport,"Brindisi Papola Casale Airport, Brindisi Salento Airport, Brindisi Casale Antonio Papolo Airport, Salento Airport"
4317,LICA,large_airport,Lamezia Terme Sant'Eufemia International Airport,38.906214,16.246007,39,EU,IT,IT-78,Lamezia Terme (CZ),yes,LICA,SUF,LICA,CZ02,,https://en.wikipedia.org/wiki/Lamezia_Terme_International_Airport,
4318,LICC,large_airport,Catania-Fontanarossa Airport,37.466801,15.0664,39,EU,IT,IT-82,Catania,yes,LICC,CTA,LICC,CT03,http://www.aeroporto.catania.it/,https://en.wikipedia.org/wiki/Catania-Fontanarossa_Airport,"Lanza Di Trabie, Catania-Fontanarossa Vincenzo Bellini"
4321,LICJ,large_airport,Falcone–Borsellino Airport,38.175999,13.091,65,EU,IT,IT-82,Palermo,yes,LICJ,PMO,LICJ,PA03,http://www.gesap.it/index.php,https://en.wikipedia.org/wiki/Falcone%E2%80%93Borsellino_Airport,"Palermo Airport, Punta Raisi Airport"
4332,LIEE,large_airport,Cagliari Elmas Airport,39.251499,9.05428,13,EU,IT,IT-88,Cagliari,yes,LIEE,CAG,LIEE,CA07,http://www.cagliariairport.it/en/,https://en.wikipedia.org/wiki/Cagliari_Elmas_Airport,"Cagliari Airport,Elmas"
4333,LIEO,large_airport,Olbia Costa Smeralda Airport,40.898953,9.518457,37,EU,IT,IT-88,Olbia (SS),yes,LIEO,OLB,LIEO,SS03,https://www.geasar.it/en,https://en.wikipedia.org/wiki/Olbia_Costa_Smeralda_Airport,
4340,LIMC,large_airport,Milan Malpensa International Airport,45.6306,8.72811,768,EU,IT,IT-25,Ferno (VA),yes,LIMC,MXP,LIMC,MI12,http://www.sea-aeroportimilano.it/en/malpensa/index.phtml,https://en.wikipedia.org/wiki/Malpensa_Airport,"MIL, Silvio Berlusconi"
4342,LIMF,large_airport,Turin Airport,45.200802,7.64963,989,EU,IT,IT-21,Caselle Torinese (TO),yes,LIMF,TRN,LIMF,TO11,http://www.aeroportoditorino.it/,https://en.wikipedia.org/wiki/Turin_International_Airport,"Torino-Caselle Airport, Sandro Pertini Airport, Turin-Caselle Airport"
4344,LIMJ,large_airport,Genoa Cristoforo Colombo Airport,44.412039,8.840732,13,EU,IT,IT-42,Genova (GE),yes,LIMJ,GOA,LIMJ,GE01,http://www.aeroportodigenova.com/en/home/index.php,https://en.wikipedia.org/wiki/Genoa_Cristoforo_Colombo_Airport,"Genova Sestri, Sestri Cristoforo Colombo Airport, Cristoforo Colombo Airport"
4354,LIPE,large_airport,Bologna Guglielmo Marconi Airport,44.5354,11.2887,123,EU,IT,IT-45,Bologna,yes,LIPE,BLQ,LIPE,BO08,http://www.bologna-airport.it/?LN=UK,https://en.wikipedia.org/wiki/Bologna_Airport,"Guglielmo Marconi Airport, Borgo Panigale Airport"
4361,LIPQ,large_airport,Trieste Airport,45.827862,13.466672,39,EU,IT,IT-36,Ronchi dei Legionari/Trieste,yes,LIPQ,TRS,LIPQ,GO02,http://www.triesteairport.it,https://en.wikipedia.org/wiki/Trieste_%E2%80%93_Friuli_Venezia_Giulia_Airport,"Ronchi Dei Legionari Airport, Trieste-Ronchi dei Legionari Airport"
4362,LIPR,large_airport,Federico Fellini International Airport,44.020024,12.612198,40,EU,IT,IT-45,Rimini (RN),yes,LIPR,RMI,LIPR,RN01,http://www.riminiairport.com/english/,https://en.wikipedia.org/wiki/Federico_Fellini_International_Airport,"Rimini Miramare Airport, Rimini San Marino International Airport"
4366,LIPX,large_airport,Verona Villafranca Valerio Catullo Airport,45.394955,10.887303,239,EU,IT,IT-34,Caselle (VR),yes,LIPX,VRN,LIPX,VR10,http://www.aeroportoverona.it/,https://en.wikipedia.org/wiki/Verona_Airport,"Valerio Catullo, Villafranca International Airport, Valerio Catullo Airport, Villafranca Airport"
4368,LIPZ,large_airport,Venice Marco Polo Airport,45.505299,12.3519,7,EU,IT,IT-34,Venezia (VE),yes,LIPZ,VCE,LIPZ,VE05,http://www.veniceairport.com/core/index.jsp?_requestid=37300&language=en,https://en.wikipedia.org/wiki/Venice_Marco_Polo_Airport,"Venezia-Tessera, Venice-Tessera"
4372,LIRF,large_airport,Rome–Fiumicino Leonardo da Vinci International Airport,41.804532,12.251998,13,EU,IT,IT-62,Rome,yes,LIRF,FCO,LIRF,RM11,http://www.adr.it/portal/portal/adr/Fiumicino/Leonardo_da_vinci/Header_Window?action=2,https://en.wikipedia.org/wiki/Rome_Fiumicino_Airport,"ROM, Rome Fiumicino Airport, Fiumicino Airport"
4378,LIRN,large_airport,Naples International Airport,40.886002,14.2908,294,EU,IT,IT-72,Napoli,yes,LIRN,NAP,LIRN,NA01,http://www.portal.gesac.it/,https://en.wikipedia.org/wiki/Naples_International_Airport,"Capodichino Airport, Aeroporto Internazionale di Napoli-Capodichino Ugo Niutta, Ariopuorto 'nternazziunale 'e Napule-Capodichino Ugo Niutta"
4379,LIRP,large_airport,Pisa International Airport,43.683899,10.3927,6,EU,IT,IT-52,Pisa (PI),yes,LIRP,PSA,LIRP,PI05,http://www.pisa-airport.com/,https://en.wikipedia.org/wiki/Pisa_International_Airport,"Galileo Galilei Airport, San Giusto Airport"
4380,LIRQ,large_airport,"Florence Airport, Peretola",43.808558,11.202822,142,EU,IT,IT-52,Firenze (FI),yes,LIRQ,FLR,LIRQ,FI04,http://www.aeroporto.firenze.it/,https://en.wikipedia.org/wiki/Florence_Airport,"Firenze, Amerigo Vespucci Airport, Aeroporto di Firenze-Peretola"
4384,LIRZ,large_airport,Perugia San Francesco d'Assisi – Umbria International Airport,43.095901,12.5132,697,EU,IT,IT-55,Perugia (PG),yes,LIRZ,PEG,LIRZ,PG10,http://www.airport.umbria.it/,https://en.wikipedia.org/wiki/Perugia_San_Francesco_d%27Assisi_%E2%80%93_Umbria_International_Airport,"Perugia San Francesco d'Assisi, Perugia Sant'Egidio Airport, Umbria International Airport"
4386,LJLJ,large_airport,Ljubljana Jože Pučnik Airport,46.223701,14.4576,1273,EU,SI,SI-012,Zgornji Brnik,yes,LJLJ,LJU,LJLJ,,http://www.lju-airport.si/,https://en.wikipedia.org/wiki/Ljubljana_Jo%C5%BEe_Pu%C4%8Dnik_Airport,"Ljubljana, Kranj"
4393,LKCS,large_airport,České Budějovice South Bohemian Airport,48.948171,14.42832,1417,EU,CZ,CZ-JC,České Budějovice,yes,LKCS,JCL,LKCS,,https://www.airport-cb.cz/,https://en.wikipedia.org/wiki/%C4%8Cesk%C3%A9_Bud%C4%9Bjovice_Airport,Jihočeské letiště České Budějovice
4399,LKKV,large_airport,Karlovy Vary Airport,50.202999,12.915,1989,EU,CZ,CZ-KA,Karlovy Vary,yes,LKKV,KLV,LKKV,,http://www.airport-k-vary.cz/indexEN.php,https://en.wikipedia.org/wiki/Karlovy_Vary_Airport,Karlovy Vary International
4402,LKMT,large_airport,Leoš Janáček Airport Ostrava,49.696301,18.111099,844,EU,CZ,CZ-MO,Mošnov,yes,LKMT,OSR,LKMT,,http://www.airport-ostrava.cz/en/,https://en.wikipedia.org/wiki/Ostrava_Leos_Janacek_Airport,Ostrava-Mošnov International Airport
4405,LKPD,large_airport,Pardubice Airport,50.015049,15.73981,741,EU,CZ,CZ-PA,Pardubice,yes,LKPD,PED,LKPD,,http://www.airport-pardubice.cz/?lang=en,https://en.wikipedia.org/wiki/Pardubice_Airport,Letiště Pardubice
4408,LKPR,large_airport,Václav Havel Airport Prague,50.100874,14.259911,1247,EU,CZ,CZ-PR,Prague,yes,LKPR,PRG,LKPR,,http://www.prg.aero,https://en.wikipedia.org/wiki/Václav_Havel_Airport_Prague,"Letiště Praha-Ruzyně, Ruzyně International Airport, Letiště Václava Havla Praha"
4427,LMML,large_airport,Malta International Airport,35.845932,14.491546,300,EU,MT,MT-25,Valletta,yes,LMML,MLA,LMML,,https://www.maltairport.com/,https://en.wikipedia.org/wiki/Malta_International_Airport,"Luqa Airport, Valletta, Gudja, RAF Luqa"
4430,LOWG,large_airport,Graz Airport,46.9911,15.4396,1115,EU,AT,AT-6,Feldkirchen bei Graz,yes,LOWG,GRZ,LOWG,,http://www.flughafen-graz.at/,https://en.wikipedia.org/wiki/Graz_Airport,"Thalerhof Airport, Fliegerhorst Nittner, Nittner Air Base, Christophorus 12"
4431,LOWI,large_airport,Innsbruck Airport,47.260201,11.344,1907,EU,AT,AT-7,Innsbruck,yes,LOWI,INN,LOWI,,http://www.innsbruck-airport.com/,https://en.wikipedia.org/wiki/Innsbruck_Airport,Kranebitten Airport
4435,LOWK,large_airport,Klagenfurt Airport,46.642502,14.3377,1472,EU,AT,AT-2,Klagenfurt am Wörthersee,yes,LOWK,KLU,LOWK,,http://www.klagenfurt-airport.com/,https://en.wikipedia.org/wiki/Klagenfurt_Airport,"Kärnten Airport, Christophorus 11"
4432,LOWL,large_airport,Linz-Hörsching Airport,48.235362,14.188128,980,EU,AT,AT-4,Linz,yes,LOWL,LNZ,LOWL,,http://www.flughafen-linz.at/,https://en.wikipedia.org/wiki/Linz_Airport,"Blue Danube Airport, Vogler Air Base, Fliegerhorst Vogler, Christophorus 10"
4433,LOWS,large_airport,Salzburg Airport,47.793301,13.0043,1411,EU,AT,AT-5,Salzburg,yes,LOWS,SZG,LOWS,,http://www.salzburg-airport.com/,https://en.wikipedia.org/wiki/Salzburg_Airport,Christophorus 6
4434,LOWW,large_airport,Vienna International Airport,48.110298,16.5697,600,EU,AT,AT-9,Vienna,yes,LOWW,VIE,LOWW,,http://www.viennaairport.com/en/,https://en.wikipedia.org/wiki/Vienna_International_Airport,
4448,LPFR,large_airport,Faro - Gago Coutinho International Airport,37.015909,-7.970939,24,EU,PT,PT-08,Faro,yes,LPFR,FAO,LPFR,,http://faroairport.pt,https://en.wikipedia.org/wiki/Faro_Airport_(Portugal),
4452,LPMA,large_airport,Cristiano Ronaldo International Airport,32.697812,-16.774613,192,EU,PT,PT-30,Funchal,yes,LPMA,FNC,LPMA,,http://www.aeroportomadeira.pt,https://en.wikipedia.org/wiki/Madeira_Airport,"Madeira, Funchal"
4456,LPPD,large_airport,João Paulo II Airport,37.7411994934,-25.6979007721,259,EU,PT,PT-20,Ponta Delgada,yes,LPPD,PDL,LPPD,,http://www.ana.pt/en-US/Aeroportos/acores/PontaDelgada/Pages/Homepage-PontaDelgada.aspx,https://en.wikipedia.org/wiki/Jo%C3%A3o_Paulo_II_Airport,
4459,LPPR,large_airport,Francisco de Sá Carneiro Airport,41.2481002808,-8.68138980865,228,EU,PT,PT-13,Porto,yes,LPPR,OPO,LPPR,,http://www.ana.pt/en-US/Aeroportos/porto/Porto/Pages/Homepage-Porto.aspx,https://en.wikipedia.org/wiki/Francisco_S%C3%A1_Carneiro_Airport,
4461,LPPT,large_airport,Lisbon Humberto Delgado Airport,38.7813,-9.13592,374,EU,PT,PT-11,Lisbon,yes,LPPT,LIS,LPPT,,https://lisbonairport.pt,https://en.wikipedia.org/wiki/Lisbon_Airport,"Lisboa, Portela"
4467,LQBK,large_airport,Banja Luka International Airport,44.941399,17.297501,400,EU,BA,BA-SRP,Mahovljani,yes,LQBK,BNX,LQBK,,http://www.banjaluka-airport.com/,https://en.wikipedia.org/wiki/Banja_Luka_International_Airport,Mahovljani Airport
4468,LQMO,large_airport,Mostar International Airport,43.282489,17.846101,156,EU,BA,BA-BIH,Mostar,yes,LQMO,OMO,LQMO,,http://www.mostar-airport.ba/,https://en.wikipedia.org/wiki/Mostar_International_Airport,
4469,LQSA,large_airport,Sarajevo International Airport,43.8246,18.331499,1708,EU,BA,BA-BIH,Sarajevo,yes,LQSA,SJJ,LQSA,,http://www.sarajevo-airport.ba/,https://en.wikipedia.org/wiki/Sarajevo_International_Airport,
4470,LQTZ,large_airport,Tuzla International Airport,44.459858,18.723627,784,EU,BA,BA-BIH,Dubrave Gornje,yes,LQTZ,TZL,LQTZ,,http://www.tuzla-airport.ba/,https://en.wikipedia.org/wiki/Tuzla_International_Airport,
4472,LRBC,large_airport,Bacău George Enescu International Airport,46.5219,26.910299,607,EU,RO,RO-BC,Bacău,yes,LRBC,BCM,LRBC,,http://www.bacauairport.ro/,https://en.wikipedia.org/wiki/Bac%C4%83u_International_Airport,"Bacau, RoAF 95th Air Base"
333670,LRBV,large_airport,Brașov-Ghimbav International Airport,45.70558,25.522871,1740,EU,RO,RO-BV,Brașov (Ghimbav),yes,LRBV,GHV,LRBV,,https://brasovairport.ro/,https://en.wikipedia.org/wiki/Brașov-Ghimbav_International_Airport,Aeroportul Internațional Brașov-Ghimbav
4475,LRCK,large_airport,Mihail Kogălniceanu International Airport,44.362202,28.4883,353,EU,RO,RO-CT,Constanța,yes,LRCK,CND,LRCK,,http://www.mk-airport.ro/,https://en.wikipedia.org/wiki/Mihail_Kog%C4%83lniceanu_International_Airport,"Constanța, Mihail Kogalniceanu, RoAF 57th Air Base"
4476,LRCL,large_airport,Avram Iancu Cluj International Airport,46.786042,23.685733,1039,EU,RO,RO-CJ,Cluj-Napoca,yes,LRCL,CLJ,LRCL,,http://www.airportcluj.ro/,https://en.wikipedia.org/wiki/Cluj_International_Airport,Someşeni Airport
4479,LRCV,large_airport,Craiova International Airport,44.3181,23.888599,626,EU,RO,RO-DJ,Craiova,yes,LRCV,CRA,LRCV,,http://www.aeroportcraiova.ro/,https://en.wikipedia.org/wiki/Craiova_International_Airport,
4480,LRIA,large_airport,Iaşi International Airport,47.179633,27.621431,411,EU,RO,RO-IS,Iaşi,yes,LRIA,IAS,LRIA,,https://www.aeroport-iasi.ro/,https://en.wikipedia.org/wiki/Ia%C5%9Fi_International_Airport,"Iasi, Jassy"
4481,LROD,large_airport,Oradea International Airport,47.025299,21.9025,465,EU,RO,RO-BH,Oradea,yes,LROD,OMR,LROD,,https://www.aeroportoradea.ro/,https://en.wikipedia.org/wiki/Oradea_International_Airport,
4482,LROP,large_airport,Bucharest Henri Coandă International Airport,44.571792,26.103285,314,EU,RO,RO-IF,Otopeni,yes,LROP,OTP,LROP,,http://www.bucharestairports.ro/en/,https://en.wikipedia.org/wiki/Henri_Coand%C4%83_International_Airport,"BUH, Otopeni Airport, RoAF 90th Airlift Base"
4483,LRSB,large_airport,Sibiu International Airport,45.785841,24.086666,1496,EU,RO,RO-SB,Sibiu,yes,LRSB,SBZ,LRSB,,http://www.sibiuairport.ro/,https://en.wikipedia.org/wiki/Sibiu_International_Airport,
4485,LRSV,large_airport,Suceava Ștefan cel Mare International Airport,47.6875,26.354099,1375,EU,RO,RO-SV,Suceava,yes,LRSV,SCV,LRSV,,http://www.aeroportsuceava.ro/index_en.html,https://en.wikipedia.org/wiki/Suceava_Airport,
4488,LRTR,large_airport,Timișoara Traian Vuia International Airport,45.809898,21.3379,348,EU,RO,RO-TM,Timişoara,yes,LRTR,TSR,LRTR,,http://www.aerotim.ro/,https://en.wikipedia.org/wiki/Traian_Vuia_International_Airport,"RoAF 93rd Air Base, Giarmata Airport, Temesvár, Temeschburg, Temeswar, Temeschwar, Тимишоара, Темишвар, Temišvar, Banat"
4490,LSGG,large_airport,Geneva International Airport,46.238098,6.10895,1411,EU,CH,CH-GE,Geneva,yes,LSGG,GVA,LSGG,,https://www.gva.ch/,https://en.wikipedia.org/wiki/Geneva_Airport,Cointrin Airport
4505,LSZH,large_airport,Zürich Airport,47.458056,8.548056,1417,EU,CH,CH-ZH,Zurich,yes,LSZH,ZRH,LSZH,,http://www.zurich-airport.com/,https://en.wikipedia.org/wiki/Zurich_Airport,
317457,LTFM,large_airport,İstanbul Airport,41.274874,28.732136,325,EU,TR,TR-34,Istanbul,yes,LTFM,IST,LTFM,,https://www.istairport.com/,https://en.wikipedia.org/wiki/Istanbul_Airport,Arnavutköy
4571,LUKK,large_airport,Chişinău International Airport,46.92774,28.931704,399,EU,MD,MD-CU,Chişinău,yes,LUKK,RMO,LUKK,,http://www.airport.md/,https://en.wikipedia.org/wiki/Chi%C5%9Fin%C4%83u_International_Airport,KIV
4572,LWOH,large_airport,Ohrid St. Paul the Apostle Airport,41.18,20.7423,2313,EU,MK,MK-003,Ohrid,yes,LWOH,OHD,LWOH,,https://ohd.airports.com.mk/,https://en.wikipedia.org/wiki/Ohrid_Airport,
4573,LWSK,large_airport,Skopje International Airport,41.958087,21.622581,781,EU,MK,MK-008,Ilinden,yes,LWSK,SKP,LWSK,,http://skp.airports.com.mk/,https://en.wikipedia.org/wiki/Skopje_Airport,Petrovec
4574,LXGB,large_airport,Gibraltar Airport,36.151679,-5.34978,12,EU,GI,GI-U-A,Gibraltar,yes,LXGB,GIB,LXGB,,https://www.gibraltarairport.gi/,https://en.wikipedia.org/wiki/Gibraltar_Airport,
4610,LYBE,large_airport,Belgrade Nikola Tesla Airport,44.818401,20.309099,335,EU,RS,RS-00,Belgrade,yes,LYBE,BEG,LYBE,,http://www.beg.aero/,https://en.wikipedia.org/wiki/Belgrade_Nikola_Tesla_Airport,"Beograd, Surčin Airport"
4612,LYNI,large_airport,Niš Constantine the Great Airport,43.336538,21.856242,648,EU,RS,RS-20,Niš,yes,LYNI,INI,LYNI,,https://ini.aerodromisrbije.rs/en/,https://en.wikipedia.org/wiki/Ni%C5%A1_Constantine_the_Great_Airport,Aerodrom Konstantin Veliki Niš
4613,LYPG,large_airport,Podgorica Airport / Podgorica Golubovci Airbase,42.359402,19.2519,141,EU,ME,ME-16,Podgorica,yes,LYPG,TGD,LYPG,,https://montenegroairports.com/en/podgorica-airport/,https://en.wikipedia.org/wiki/Podgorica_Airport,"Podgorica Airbase, Golubovci Airbase, Аеродром Подгорица, Aerodrom Podgorica"
4617,LZIB,large_airport,M. R. Štefánik Airport,48.1702,17.2127,436,EU,SK,SK-BL,Bratislava,yes,LZIB,BTS,LZIB,,,https://en.wikipedia.org/wiki/M._R._%C5%A0tef%C3%A1nik_Airport,
6467,UKBB,large_airport,Boryspil International Airport,50.345001,30.894699,427,EU,UA,UA-32,Boryspil,no,UKBB,KBP,UKBB,,https://kbp.aero/en/,https://en.wikipedia.org/wiki/Boryspil_International_Airport,"Borispol, Міжнародний аеропорт ""Бориспіль"", Kyiv"
6474,UKFF,large_airport,Simferopol International Airport,45.0522,33.975101,639,EU,UA,UA-43,Simferopol,no,UKFF,SIP,UKFF,URFF,https://new.sipaero.ru/,https://en.wikipedia.org/wiki/Simferopol_International_Airport,
6481,UKLL,large_airport,Lviv International Airport,49.8125,23.9561,1071,EU,UA,UA-46,Lviv,no,UKLL,LWO,UKLL,,http://www.airport.lviv.ua/index.php?id=home&L=1,https://en.wikipedia.org/wiki/Lviv_International_Airport,"Міжнародний аеропорт ""Львів"""
6487,UKOO,large_airport,Odesa International Airport,46.427196,30.672649,172,EU,UA,UA-51,Odesa,no,UKOO,ODS,,,https://www.airport.od.ua/,https://en.wikipedia.org/wiki/Odessa_International_Airport,"ODS, UKOO, УКОО, Odesa Central, Міжнародний аеропорт Одеса, Odessa Airport"
6489,ULLI,large_airport,Pulkovo Airport,59.800301,30.262501,78,EU,RU,RU-SPE,St. Petersburg,yes,ULLI,LED,ULLI,,https://pulkovoairport.ru/,https://en.wikipedia.org/wiki/Pulkovo_Airport,"Аэропо́рт Пу́лково, Leningrad, Shosseynaya Airport, Saint Petersburg, Petrograd"
6490,ULMM,large_airport,Emperor Nicholas II Murmansk Airport,68.7817,32.750801,266,EU,RU,RU-MUR,Murmansk,yes,ULMM,MMK,ULMM,,http://www.airport-murmansk.ru/,https://en.wikipedia.org/wiki/Murmansk_Airport,"Аэропо́рт Му́рманска им. Николая II or Аэропорт Мурмаши, Мурманск"
6495,UMBB,large_airport,Brest International Airport,52.108138,23.89676,468,EU,BY,BY-BR,Brest,yes,UMBB,BQT,UMBB,,https://www.ban.by/filialy/brest,https://en.wikipedia.org/wiki/Brest_Airport,Аэропорт Брест
6498,UMKK,large_airport,Khrabrovo Airport,54.89161,20.598559,42,EU,RU,RU-KGD,Kaliningrad,yes,UMKK,KGD,UMKK,,https://kgdavia.ru/,https://en.wikipedia.org/wiki/Khrabrovo_Airport,"УМКК, Храброво"
6501,UMMS,large_airport,Minsk National Airport,53.888071,28.039964,670,EU,BY,BY-MI,Minsk,yes,UMMS,MSQ,UMMS,,http://airport.by/en,https://en.wikipedia.org/wiki/Minsk_National_Airport,"Нацыянальны аэрапорт, Minsk 2 Airport"
6507,UNNT,large_airport,Novosibirsk Tolmachevo Airport,55.019756,82.618675,365,EU,RU,RU-NVS,Novosibirsk,yes,UNNT,OVB,UNNT,,http://eng.tolmachevo.ru/,https://en.wikipedia.org/wiki/Tolmachevo_Airport,
6512,URKK,large_airport,Krasnodar Pashkovsky International Airport,45.034465,39.174215,118,EU,RU,RU-KDA,Krasnodar,yes,URKK,KRR,URKK,,https://krr.aero/en/,https://en.wikipedia.org/wiki/Pashkovsky_Airport,"Pashkovskiy Airport, Pashkovsky Airport"
6514,URMM,large_airport,Mineralnye Vody Airport,44.225101,43.081902,1054,EU,RU,RU-STA,Mineralnyye Vody,yes,URMM,MRV,URMM,,,https://en.wikipedia.org/wiki/Mineralnye_Vody_Airport,
326363,URRP,large_airport,Platov International Airport,47.493888,39.924722,213,EU,RU,RU-ROS,Rostov-on-Don,no,URRP,ROV,URRP,,,https://en.wikipedia.org/wiki/Platov_International_Airport,"УРРП, Международный аэропорт Платов"
6519,URSS,large_airport,Sochi International Airport,43.449902,39.9566,89,EU,RU,RU-KDA,Sochi,yes,URSS,AER,URSS,,https://aer.aero/en,https://en.wikipedia.org/wiki/Sochi_International_Airport,"Adler Airport, Black Sea, Международный Аэропорт Сочи"
6522,URWW,large_airport,Volgograd International Airport,48.78134,44.339194,482,EU,RU,RU-VGG,Volgograd,yes,URWW,VOG,URWW,,,https://en.wikipedia.org/wiki/Volgograd_International_Airport,"Gumrak Airport, Аэропорт Волгоград, Аэропорт Гумрак"
26395,UUDL,large_airport,Golden Ring Yaroslavl International Airport,57.560699,40.157398,287,EU,RU,RU-YAR,Tunoshna,yes,UUDL,IAR,UUDL,,,https://en.wikipedia.org/wiki/Tunoshna,
26396,UUEE,large_airport,Sheremetyevo International Airport,55.976858,37.41121,622,EU,RU,RU-MOS,Moscow,yes,UUEE,SVO,UUEE,,http://svo.aero/,https://en.wikipedia.org/wiki/Sheremetyevo_International_Airport,"УУЕЕ, Москва, MOW, Международный аэропорт Шереметьево, Alexander S. Pushkin"
26403,UWGG,large_airport,Nizhny Novgorod / Strigino International Airport,56.227351,43.785152,256,EU,RU,RU-NIZ,Nizhny Novgorod,yes,UWGG,GOJ,UWGG,,http://www.airportnn.ru/en/,https://en.wikipedia.org/wiki/Nizhny_Novgorod_International_Airport,"УВГГ, ЬВГГ, Нижний Новгород / Стригино"
42965,UWPS,large_airport,Saransk International Airport,54.12513,45.212257,676,EU,RU,RU-MO,Saransk,yes,UWPS,SKX,UWPS,,,https://en.wikipedia.org/wiki/Saransk_Airport,Аэропорт Саранск
26411,UWUU,large_airport,Ufa International Airport,54.557499,55.874401,449,EU,RU,RU-BA,Ufa,yes,UWUU,UFA,UWUU,,http://www.airportufa.ru/en/,https://en.wikipedia.org/wiki/Ufa_International_Airport,"УВУУ, Международный аэропорт Уфа"
26412,UWWW,large_airport,Kurumoch International Airport,53.504902,50.164299,477,EU,RU,RU-SAM,Samara,yes,UWWW,KUF,UWWW,,https://kuf.aero/en/,https://en.wikipedia.org/wiki/Kurumoch_International_Airport,"УВВВ, Самара / Курумоч"
1 id ident type name latitude_deg longitude_deg elevation_ft continent iso_country iso_region municipality scheduled_service icao_code iata_code gps_code local_code home_link wikipedia_link keywords
2 117 BIAR large_airport Akureyri International Airport 65.656573 -18.072018 6 EU IS IS-6 Akureyri yes BIAR AEY BIAR https://www.innanlandsflugvellir.is/en/akureyri-airport/ https://en.wikipedia.org/wiki/Akureyri_Airport
3 123 BIKF large_airport Keflavik International Airport 63.985001 -22.6056 171 EU IS IS-2 Reykjavík yes BIKF KEF BIKF https://www.isavia.is/en/keflavik-airport https://en.wikipedia.org/wiki/Keflav%C3%ADk_International_Airport Keflavik Naval Air Station, REK
4 4614 BKPR large_airport Priština Adem Jashari International Airport 42.5728 21.035801 1789 EU XK XK-01 Prishtina yes BKPR PRN BKPR http://www.airportpristina.com/ https://en.wikipedia.org/wiki/Pristina_International_Airport LYPR, Pristina, Slatina Air Base
5 2155 EBBR large_airport Brussels Airport 50.901402 4.48444 175 EU BE BE-VBR Zaventem yes EBBR BRU EBBR http://www.brusselsairport.be/en/ https://en.wikipedia.org/wiki/Brussels_Airport Brussels National, Zaventem, EBMB
6 2163 EBOS large_airport Ostend-Bruges International Airport 51.1998 2.874673 13 EU BE BE-VWV Oostende yes EBOS OST EBOS http://www.ost.aero/ https://en.wikipedia.org/wiki/Ostend-Bruges_International_Airport Ostend-Bruges International Airport
7 301881 EDDB large_airport Berlin Brandenburg Airport 52.361738 13.502341 157 EU DE DE-BR Berlin yes EDDB BER EDDB https://ber.berlin-airport.de/ https://en.wikipedia.org/wiki/Berlin_Brandenburg_Airport Willy Brandt
8 2210 EDDC large_airport Dresden Airport 51.134123 13.767831 755 EU DE DE-SN Dresden yes EDDC DRS EDDC http://www.dresden-airport.de/en/index.html https://en.wikipedia.org/wiki/Dresden_Airport ETDN
9 2211 EDDE large_airport Erfurt-Weimar Airport 50.978281 10.960713 1036 EU DE DE-TH Erfurt yes EDDE ERF EDDE http://www.airport-erfurt.de/ https://en.wikipedia.org/wiki/Erfurt_Airport Flughafen Erfurt-Weimar
10 2212 EDDF large_airport Frankfurt Main Airport 50.026706 8.55835 364 EU DE DE-HE Frankfurt am Main yes EDDF FRA EDDF https://www.frankfurt-airport.de/ https://en.wikipedia.org/wiki/Frankfurt_Airport EDAF, Frankfurt am Main, Frankfurt Main, Rhein-Main Air Base, Zeppelinheim
11 2213 EDDG large_airport Münster Osnabrück Airport 52.133816 7.688482 160 EU DE DE-NW Greven yes EDDG FMO EDDG https://www.fmo.de/ https://en.wikipedia.org/wiki/M%C3%BCnster_Osnabr%C3%BCck_International_Airport
12 2214 EDDH large_airport Hamburg Helmut Schmidt Airport 53.630402 9.98823 53 EU DE DE-HH Hamburg yes EDDH HAM EDDH https://www.hamburg-airport.de/en/ https://en.wikipedia.org/wiki/Hamburg_Airport Hamburg-Fuhlsbüttel Airport
13 2216 EDDK large_airport Cologne Bonn Airport 50.865898 7.14274 302 EU DE DE-NW Köln (Cologne) yes EDDK CGN EDDK http://www.koeln-bonn-airport.de/en/ https://en.wikipedia.org/wiki/Cologne_Bonn_Airport Köln
14 2217 EDDL large_airport Düsseldorf Airport 51.289501 6.76678 147 EU DE DE-NW Düsseldorf yes EDDL DUS EDDL http://www.dus.com/dus_en/ https://en.wikipedia.org/wiki/D%C3%BCsseldorf_Airport
15 2218 EDDM large_airport Munich Airport 48.353802 11.7861 1487 EU DE DE-BY Munich yes EDDM MUC EDDM http://www.munich-airport.com/ https://en.wikipedia.org/wiki/Munich_Airport Franz Josef Strauss Airport, Flughafen München Franz Josef Strauß
16 2219 EDDN large_airport Nuremberg Airport 49.498699 11.078056 1046 EU DE DE-BY Nuremberg yes EDDN NUE EDDN https://www.airport-nuernberg.de/english https://en.wikipedia.org/wiki/Nuremberg_Airport Nürnberg
17 2220 EDDP large_airport Leipzig/Halle Airport 51.420657 12.232705 465 EU DE DE-SN Schkeuditz yes EDDP LEJ EDDP https://www.mdf-ag.com/ https://en.wikipedia.org/wiki/Leipzig/Halle_Airport Schkeuditz Airport
18 2222 EDDS large_airport Stuttgart Airport 48.689899 9.22196 1276 EU DE DE-BW Stuttgart yes EDDS STR EDDS http://www.flughafen-stuttgart.de/ https://en.wikipedia.org/wiki/Stuttgart_Airport
19 2224 EDDV large_airport Hannover Airport 52.461102 9.68508 183 EU DE DE-NI Hannover yes EDDV HAJ EDDV https://www.hannover-airport.de https://en.wikipedia.org/wiki/Hanover/Langenhagen_International_Airport
20 2225 EDDW large_airport Bremen Airport 53.046786 8.78932 14 EU DE DE-HB Bremen yes EDDW BRE EDDW https://www.bremen-airport.com https://en.wikipedia.org/wiki/Bremen_Airport
21 2227 EDFH large_airport Frankfurt-Hahn Airport 49.946353 7.261734 1649 EU DE DE-RP Frankfurt am Main (Lautzenhausen) yes EDFH HHN EDFH https://www.hahn-airport.de/en/home https://en.wikipedia.org/wiki/Frankfurt-Hahn_Airport
22 2237 EDJA large_airport Memmingen Allgau Airport 47.988092 10.238248 2077 EU DE DE-BY Memmingen yes EDJA FMM EDJA https://en.wikipedia.org/wiki/Memmingen_Airport ETSM
23 2244 EDLP large_airport Paderborn Lippstadt Airport 51.612527 8.617459 699 EU DE DE-NW Büren yes EDLP PAD EDLP https://en.wikipedia.org/wiki/Paderborn_Lippstadt_Airport
24 2247 EDLW large_airport Dortmund Airport 51.518299 7.61224 425 EU DE DE-NW Dortmund yes EDLW DTM EDLW https://www.dortmund-airport.com/ https://en.wikipedia.org/wiki/Dortmund_Airport
25 2256 EDNY large_airport Bodensee Airport Friedrichshafen 47.671299 9.51149 1367 EU DE DE-BW Friedrichshafen yes EDNY FDH EDNY https://www.bodensee-airport.eu https://en.wikipedia.org/wiki/Friedrichshafen_Airport Bodensee Airport Friedrichshafen
26 2270 EDSB large_airport Karlsruhe Baden-Baden Airport 48.7794 8.0805 408 EU DE DE-BW Rheinmünster yes EDSB FKB EDSB http://www.baden-airpark.de/startseite.html https://en.wikipedia.org/wiki/Baden_Airpark Baden Airpark
27 2285 EDVK large_airport Kassel Airport 51.418395 9.391638 820 EU DE DE-HE Calden yes EDVK KSF EDVK http://www.flughafenkassel.de/ https://en.wikipedia.org/wiki/Kassel_Airport Kassel-Calden
28 2301 EETN large_airport Lennart Meri Tallinn Airport 59.413246 24.83264 131 EU EE EE-37 Tallinn yes EETN TLL EETN https://airport.ee/ https://en.wikipedia.org/wiki/Tallinn_Airport Tallinna Lennujaam
29 2307 EFHK large_airport Helsinki Vantaa Airport 60.318363 24.963341 179 EU FI FI-18 Helsinki (Vantaa) yes EFHK HEL EFHK https://www.finavia.fi/en/airports/helsinki-airport https://en.wikipedia.org/wiki/Helsinki_Airport
30 2314 EFIV large_airport Ivalo Airport 68.6073 27.4053 481 EU FI FI-10 Ivalo yes EFIV IVL EFIV http://www.finavia.fi/en/ivalo/ https://en.wikipedia.org/wiki/Ivalo_Airport
31 2325 EFKT large_airport Kittilä International Airport 67.700996 24.8468 644 EU FI FI-10 Kittilä yes EFKT KTT EFKT http://www.finavia.fi/en/kittila/ https://en.wikipedia.org/wiki/Kittil%C3%A4_Airport
32 2326 EFKU large_airport Kuopio Airport 63.007099 27.7978 323 EU FI FI-15 Kuopio / Siilinjärvi yes EFKU KUO EFKU https://www.finavia.fi/en/kuopio/ https://en.wikipedia.org/wiki/Kuopio_Airport
33 2329 EFLP large_airport Lappeenranta Airport 61.044601 28.144743 349 EU FI FI-02 Lappeenranta yes EFLP LPP EFLP https://lppairport.fi/en https://en.wikipedia.org/wiki/Lappeenranta_Airport
34 2334 EFOU large_airport Oulu Airport 64.930099 25.354601 47 EU FI FI-14 Oulu / Oulunsalo yes EFOU OUL EFOU https://www.finavia.fi/en/oulu/ https://en.wikipedia.org/wiki/Oulu_Airport
35 2341 EFRO large_airport Rovaniemi Airport 66.563327 25.829751 642 EU FI FI-10 Rovaniemi yes EFRO RVN EFRO http://www.finavia.fi/en/rovaniemi/ https://en.wikipedia.org/wiki/Rovaniemi_Airport
36 2347 EFTP large_airport Tampere-Pirkkala Airport 61.414101 23.604401 390 EU FI FI-11 Tampere / Pirkkala yes EFTP TMP EFTP https://www.finavia.fi/en/tampere-pirkkala/ https://en.wikipedia.org/wiki/Tampere-Pirkkala_Airport
37 2349 EFTU large_airport Turku Airport 60.514099 22.2628 161 EU FI FI-19 Turku yes EFTU TKU EFTU https://www.finavia.fi/en/turku/ https://en.wikipedia.org/wiki/Turku_Airport
38 2351 EFVA large_airport Vaasa Airport 63.05023 21.762543 19 EU FI FI-12 Vaasa yes EFVA VAA EFVA http://www.finavia.fi/en/vaasa/ https://en.wikipedia.org/wiki/Vaasa_Airport
39 2385 EGAA large_airport Belfast International Airport 54.6575012207 -6.2158298492399995 268 EU GB GB-NIR Belfast yes EGAA BFS EGAA http://www.belfastairport.com/ https://en.wikipedia.org/wiki/Belfast_International_Airport Aldergrove, RAF Aldergrove
40 2389 EGBB large_airport Birmingham Airport 52.453899 -1.74803 327 EU GB GB-ENG Birmingham, West Midlands yes EGBB BHX EGBB https://www.birminghamairport.co.uk/ https://en.wikipedia.org/wiki/Birmingham_Airport Birmingham International Airport
41 2398 EGCC large_airport Manchester Airport 53.349375 -2.279521 257 EU GB GB-ENG Manchester, Greater Manchester yes EGCC MAN EGCC http://www.manchesterairport.co.uk/ https://en.wikipedia.org/wiki/Manchester_Airport Ringway Airport, RAF Ringway
42 2415 EGFF large_airport Cardiff International Airport 51.396702 -3.34333 220 EU GB GB-WLS Cardiff yes EGFF CWL EGFF http://www.cwlfly.com/ https://en.wikipedia.org/wiki/Cardiff_Airport Caerdydd, Cardiff International Airport, Maes Awyr Caerdydd, Rhoose, Y Rhws
43 2417 EGGD large_airport Bristol Airport 51.382326 -2.716453 622 EU GB GB-ENG Bristol yes EGGD BRS EGGD http://www.bristolairport.co.uk/ https://en.wikipedia.org/wiki/Bristol_Airport Lulsgate Bottom
44 2418 EGGP large_airport Liverpool John Lennon Airport 53.334863 -2.849637 80 EU GB GB-ENG Liverpool yes EGGP LPL EGGP http://www.liverpoolairport.com/ https://en.wikipedia.org/wiki/Liverpool_John_Lennon_Airport
45 2434 EGLL large_airport London Heathrow Airport 51.470748 -0.459909 83 EU GB GB-ENG London yes EGLL LHR EGLL http://www.heathrow.com/ https://en.wikipedia.org/wiki/Heathrow_Airport LON, Londres
46 2444 EGNM large_airport Leeds Bradford Airport 53.865898 -1.66057 681 EU GB GB-ENG Leeds, West Yorkshire yes EGNM LBA EGNM http://www.lbia.co.uk/ https://en.wikipedia.org/wiki/Leeds_Bradford_International_Airport
47 2447 EGNS large_airport Isle of Man Airport 54.083081 -4.623871 52 EU IM IM-U-A Castletown yes EGNS IOM EGNS http://www.iom-airport.com/ https://en.wikipedia.org/wiki/Isle_of_Man_Airport Ronaldsway, Douglas
48 2448 EGNT large_airport Newcastle International Airport 55.037958 -1.689577 266 EU GB GB-ENG Newcastle upon Tyne, Tyne and Wear yes EGNT NCL EGNT http://www.newcastleairport.com/ https://en.wikipedia.org/wiki/Newcastle_International_Airport
49 2450 EGNX large_airport East Midlands Airport 52.8311 -1.32806 306 EU GB GB-ENG Nottingham, Leicestershire yes EGNX EMA EGNX http://www.eastmidlandsairport.com/ https://en.wikipedia.org/wiki/East_Midlands_Airport RAF Castle Donington
50 2459 EGPD large_airport Aberdeen International Airport 57.2019 -2.19778 215 EU GB GB-SCT Aberdeen yes EGPD ABZ EGPD http://www.aberdeenairport.com/ https://en.wikipedia.org/wiki/Aberdeen_Airport Dyce
51 2461 EGPF large_airport Glasgow Airport 55.871899 -4.43306 26 EU GB GB-SCT Glasgow yes EGPF GLA EGPF http://www.glasgowairport.com/ https://en.wikipedia.org/wiki/Glasgow_International_Airport Port-adhair Eadar-nàiseanta Ghlaschu, RAF Abbotsinch, RAF Ayr
52 2462 EGPH large_airport Edinburgh Airport 55.950145 -3.372288 135 EU GB GB-SCT Ingliston, Edinburgh yes EGPH EDI EGPH http://www.edinburghairport.com/ https://en.wikipedia.org/wiki/Edinburgh_Airport
53 2513 EHAM large_airport Amsterdam Airport Schiphol 52.308601 4.76389 -11 EU NL NL-NH Amsterdam yes EHAM AMS EHAM https://www.schiphol.nl/ https://en.wikipedia.org/wiki/Amsterdam_Airport_Schiphol
54 2515 EHBK large_airport Maastricht Aachen Airport 50.911087 5.769401 375 EU NL NL-LI Maastricht yes EHBK MST EHBK https://en.wikipedia.org/wiki/Maastricht_Aachen_Airport
55 2518 EHEH large_airport Eindhoven Airport 51.4500999451 5.37452983856 74 EU NL NL-NB Eindhoven yes EHEH EIN EHEH http://www.eindhovenairport.nl/ https://en.wikipedia.org/wiki/Eindhoven_Airport Welschap Airport
56 2519 EHGG large_airport Groningen Airport Eelde 53.119107 6.577652 17 EU NL NL-DR Groningen yes EHGG GRQ EHGG https://en.wikipedia.org/wiki/Groningen_Airport_Eelde
57 2524 EHRD large_airport Rotterdam The Hague Airport 51.956902 4.43722 -15 EU NL NL-ZH Rotterdam yes EHRD RTM EHRD https://www.rotterdamthehagueairport.nl/ https://en.wikipedia.org/wiki/Rotterdam_The_Hague_Airport Vliegveld Zestienhoven
58 2530 EICK large_airport Cork International Airport 51.841301 -8.49111 502 EU IE IE-CO Cork yes EICK ORK EICK http://www.corkairport.com/ https://en.wikipedia.org/wiki/Cork_Airport Aerfort Chorcaí
59 2533 EIDW large_airport Dublin Airport 53.428713 -6.262121 242 EU IE IE-D Dublin yes EIDW DUB EIDW http://www.dublinairport.com/ https://en.wikipedia.org/wiki/Dublin_Airport Aerfort Bhaile Átha Cliath
60 2534 EIKN large_airport Ireland West Airport Knock 53.910439 -8.817002 665 EU IE IE-MO Charlestown yes EIKN NOC EIKN http://www.irelandwestairport.com/ https://en.wikipedia.org/wiki/Ireland_West_Airport_Knock Connaught, Horan International Airport
61 2537 EINN large_airport Shannon Airport 52.702 -8.92482 46 EU IE IE-CE Shannon yes EINN SNN EINN http://www.shannonairport.com/ https://en.wikipedia.org/wiki/Shannon_Airport
62 2540 EKAH large_airport Aarhus Airport 56.303331 10.618286 82 EU DK DK-82 Aarhus yes EKAH AAR EKAH http://www.aar.dk/ https://en.wikipedia.org/wiki/Aarhus_Airport Tirstrup
63 2541 EKBI large_airport Billund Airport 55.740335 9.157019 247 EU DK DK-83 Billund yes EKBI BLL EKBI http://www.billund-airport.dk/?sc_lang=en https://en.wikipedia.org/wiki/Billund_Airport
64 2542 EKCH large_airport Copenhagen Kastrup Airport 55.617900848389 12.656000137329 17 EU DK DK-84 Copenhagen yes EKCH CPH EKCH http://www.cph.dk/en/ https://en.wikipedia.org/wiki/Copenhagen_Airport København, Malmö
65 2549 EKOD large_airport Odense Hans Christian Andersen Airport 55.475307 10.327206 56 EU DK DK-83 Odense yes EKOD ODE EKOD https://en.wikipedia.org/wiki/Hans_Christian_Andersen_Airport Beldringe
66 2559 EKVG large_airport Vágar Airport 62.063256 -7.275782 280 EU FO FO-U-A Vágar yes EKVG FAE EKVG https://www.fae.fo/ https://en.wikipedia.org/wiki/V%C3%A1gar_Airport Faroes, RAF Vágar
67 2562 EKYT large_airport Aalborg Airport 57.094763 9.84993 10 EU DK DK-81 Aalborg yes EKYT AAL EKYT http://www.aal.dk https://en.wikipedia.org/wiki/Aalborg_Airport
68 2563 ELLX large_airport Luxembourg-Findel International Airport 49.626845 6.212134 1234 EU LU LU-L Luxembourg yes ELLX LUX ELLX https://www.lux-airport.lu/en/ https://en.wikipedia.org/wiki/Luxembourg-Findel_International_Airport
69 2564 ENAL large_airport Ålesund Airport 62.560443 6.110845 69 EU NO NO-15 Ålesund yes ENAL AES ENAL https://www.avinor.no/airport/alesund/ https://en.wikipedia.org/wiki/%C3%85lesund_Airport%2C_Vigra Vigra, Ålesund Vigra Airport
70 2569 ENBO large_airport Bodø Airport 67.269203 14.3653 42 EU NO NO-18 Bodø yes ENBO BOO ENBO http://www.avinor.no/en/airport/bodo/ https://en.wikipedia.org/wiki/Bod%C3%B8_Airport Bodø Air Force Base, Bodø Main Air Station
71 2570 ENBR large_airport Bergen Airport, Flesland 60.2934 5.21814 170 EU NO NO-46 Bergen yes ENBR BGO ENBR http://www.avinor.no/en/airport/bergen https://en.wikipedia.org/wiki/Bergen_Airport%2C_Flesland
72 2572 ENCN large_airport Kristiansand Airport 58.204201 8.08537 57 EU NO NO-42 Kristiansand(Kjevik) yes ENCN KRS ENCN http://www.avinor.no/en/airport/kristiansand https://en.wikipedia.org/wiki/Kristiansand_Airport%2C_Kjevik Kjevik Airport
73 2575 ENEV large_airport Harstad/Narvik Airport 68.491302 16.678101 84 EU NO NO-18 Evenes yes ENEV EVE ENEV https://avinor.no/en/airport/harstadnarvik-airport/ https://en.wikipedia.org/wiki/Harstad/Narvik_Airport%2C_Evenes Harstad/Narvik lufthavn, Evenes, Evenes Airport
74 2578 ENGM large_airport Oslo-Gardermoen International Airport 60.193901 11.1004 681 EU NO NO-32 Oslo (Gardermoen) yes ENGM OSL ENGM https://avinor.no/en/airport/oslo-airport https://en.wikipedia.org/wiki/Oslo_Airport,_Gardermoen
75 2599 ENTC large_airport Tromsø Airport 69.683296 18.9189 31 EU NO NO-55 Tromsø yes ENTC TOS ENTC http://www.avinor.no/en/airport/tromso https://en.wikipedia.org/wiki/Troms%C3%B8_Airport Langnes, Tromsøya, Tromso
76 2600 ENTO large_airport Sandefjord Airport, Torp 59.186699 10.2586 286 EU NO NO-39 Sandefjord(Torp) yes ENTO TRF ENTO https://torp.no/ https://en.wikipedia.org/wiki/Sandefjord_Airport%2C_Torp Torp
77 2601 ENVA large_airport Trondheim Airport, Værnes 63.457802 10.924 56 EU NO NO-50 Trondheim yes ENVA TRD ENVA https://avinor.no/en/airport/trondheim-airport/ https://en.wikipedia.org/wiki/Trondheim_Airport%2C_V%C3%A6rnes
78 2602 ENZV large_airport Stavanger Airport, Sola 58.876701 5.63778 29 EU NO NO-11 Stavanger yes ENZV SVG ENZV http://www.avinor.no/en/airport/stavanger/ https://en.wikipedia.org/wiki/Stavanger_Airport,_Sola Sola Air Station, Stavanger lufthavn, Sola
79 2608 EPGD large_airport Gdańsk Lech Wałęsa Airport 54.377602 18.4662 489 EU PL PL-PM Gdańsk yes EPGD GDN EPGD http://www.airport.gdansk.pl/ https://en.wikipedia.org/wiki/Gda%C5%84sk_Lech_Wa%C5%82%C4%99sa_Airport Gdańsk-Rębiechowo
80 2609 EPKK large_airport Kraków John Paul II International Airport 50.077702 19.7848 791 EU PL PL-MA Balice yes EPKK KRK EPKK http://www.krakowairport.pl/en/ https://en.wikipedia.org/wiki/Kraków_John_Paul_II_International_Airport Krakov
81 2613 EPKT large_airport Katowice Wojciech Korfanty International Airport 50.476015 19.080705 995 EU PL PL-SL Katowice yes EPKT KTW EPKT http://katowice.airport.com https://en.wikipedia.org/wiki/Katowice_International_Airport Pyrzowice, Dąbrowa Górnicza, Katowice, Silesia, GOP, Silesia Metropolis, Gliwice, Piekary Śląskie, Bytom, Zabrze, Sosnowiec
82 309484 EPLB large_airport Lublin Airport 51.240157 22.713461 633 EU PL PL-LU Lublin yes EPLB LUZ EPLB https://en.wikipedia.org/wiki/Lublin_Airport
83 2616 EPLL large_airport Łódź Władysław Reymont Airport 51.721901 19.3981 604 EU PL PL-LD Łódź yes EPLL LCJ EPLL https://www.lodz-airport.pl/en https://en.wikipedia.org/wiki/%C5%81%C3%B3d%C5%BA_W%C5%82adys%C5%82aw_Reymont_Airport Lublinek
84 2625 EPPO large_airport Poznań-Ławica Airport 52.421598 16.823359 308 EU PL PL-WP Poznań yes EPPO POZ EPPO https://poznanairport.pl https://en.wikipedia.org/wiki/Pozna%C5%84-%C5%81awica_Airport Poznań–Ławica Henryk Wieniawski Airport
85 2629 EPRZ large_airport Rzeszów-Jasionka Airport 50.109791 22.024155 693 EU PL PL-PK Jasionka yes EPRZ RZE EPRZ https://www.rzeszowairport.pl https://en.wikipedia.org/wiki/Rzesz%C3%B3w-Jasionka_Airport
86 2630 EPSC large_airport Solidarity Szczecin–Goleniów Airport 53.584702 14.9022 154 EU PL PL-ZP Szczecin(Glewice) yes EPSC SZZ EPSC http://www.airport.com.pl/en/index.php https://en.wikipedia.org/wiki/Szczecin-Goleni%C3%B3w_%22Solidarno%C5%9B%C4%87%22_Airport Solidarność, Glewice, Port Lotniczy Szczecin–Goleniów im. NSZZ Solidarność
87 2637 EPWA large_airport Warsaw Chopin Airport 52.165699 20.9671 362 EU PL PL-MZ Warsaw yes EPWA WAW EPWA http://www.lotnisko-chopina.pl/?lang=en https://en.wikipedia.org/wiki/Warsaw_Frederic_Chopin_Airport Okęcie, Warszawa
88 2638 EPWR large_airport Copernicus Wrocław Airport 51.103719 16.882096 404 EU PL PL-DS Wrocław yes EPWR WRO EPWR http://www.airport.wroclaw.pl/en/index.php https://en.wikipedia.org/wiki/Copernicus_Airport_Wroc%C5%82aw Strachowice, Breslau, Wroclaw
89 2648 ESGG large_airport Göteborg Landvetter Airport 57.6628 12.2798 506 EU SE SE-Q Göteborg yes ESGG GOT ESGG https://www.swedavia.com/landvetter/ https://en.wikipedia.org/wiki/G%C3%B6teborg_Landvetter_Airport Göteborg-Landvetter, Gothenburg
90 2660 ESKN large_airport Stockholm Skavsta Airport 58.789656 16.911479 140 EU SE SE-D Nyköping yes ESKN NYO ESKN https://en.wikipedia.org/wiki/Stockholm-Skavsta_Airport
91 316498 ESKS large_airport Scandinavian Mountains Airport 61.16508 12.83352 1608 EU SE SE-W Malung-Sälen yes ESKS SCR ESKS https://scandinavianmountains.se/en/home/ https://en.wikipedia.org/wiki/Scandinavian_Mountains_Airport Sälens Flygplats, Trysil, Sälen Trysil Airport, Sälen/Scandinavian Mountains Airport
92 2669 ESMS large_airport Malmö Sturup Airport 55.535564 13.376327 236 EU SE SE-M Malmö yes ESMS MMX ESMS http://www.swedavia.com/malmo/ https://en.wikipedia.org/wiki/Malm%C3%B6_Airport
93 2685 ESNQ large_airport Kiruna Airport 67.821999 20.3368 1508 EU SE SE-BD Kiruna yes ESNQ KRN ESNQ https://www.swedavia.com/kiruna/ https://en.wikipedia.org/wiki/Kiruna_Airport
94 2689 ESNU large_airport Umeå Airport 63.791801 20.282801 24 EU SE SE-AC Umeå yes ESNU UME ESNU https://www.swedavia.com/umea/ https://en.wikipedia.org/wiki/Ume%C3%A5_Airport
95 2696 ESOW large_airport Stockholm Västerås Airport 59.589401 16.6336 21 EU SE SE-U Stockholm / Västerås yes ESOW VST ESOW https://en.wikipedia.org/wiki/Stockholm-V%C3%A4ster%C3%A5s_Airport
96 2697 ESPA large_airport Luleå Airport 65.5438 22.122 65 EU SE SE-BD Luleå yes ESPA LLA ESPA https://www.swedavia.com/lulea/ https://en.wikipedia.org/wiki/Lule%C3%A5_Airport Kallax Air Base
97 2701 ESSA large_airport Stockholm-Arlanda Airport 59.64849 17.928829 137 EU SE SE-AB Stockholm yes ESSA ARN ESSA http://www.swedavia.se/arlanda/ https://en.wikipedia.org/wiki/Stockholm-Arlanda_Airport
98 2706 ESSL large_airport Linköping City Airport 58.404909 15.684499 172 EU SE SE-E Linköping yes ESSL LPI ESSL http://www.linkopingsflygplats.se/ https://en.wikipedia.org/wiki/Link%C3%B6ping_SAAB_Airport
99 2710 ESSV large_airport Visby Airport 57.6628 18.346201 164 EU SE SE-I Visby yes ESSV VBY ESSV https://www.swedavia.com/visby/ https://en.wikipedia.org/wiki/Visby_Airport
100 2757 EVLA large_airport Liepāja International Airport 56.517502 21.096901 16 EU LV LV-112 Liepāja no EVLA LPX EVLA http://www.liepaja-airport.lv/ https://en.wikipedia.org/wiki/Liep%C4%81ja_International_Airport
101 2758 EVRA large_airport Riga International Airport 56.920752 23.970711 36 EU LV LV-062 Riga yes EVRA RIX EVRA http://www.riga-airport.com/ https://en.wikipedia.org/wiki/Riga_International_Airport
102 2760 EYKA large_airport Kaunas International Airport 54.963965 24.08582 256 EU LT LT-KU Kaunas yes EYKA KUN EYKA http://www.kaunasair.lt/index.php?lang=2&m=2&p=210 https://en.wikipedia.org/wiki/Kaunas_Airport Kauno tarptautinis Oro Uostas
103 2762 EYPA large_airport Palanga International Airport 55.973202 21.093901 33 EU LT LT-KL Palanga yes EYPA PLQ EYPA http://www.palanga-airport.lt/ https://en.wikipedia.org/wiki/Palanga_International_Airport
104 2766 EYVI large_airport Vilnius International Airport 54.634102 25.285801 648 EU LT LT-VL Vilnius yes EYVI VNO EYVI http://www.vilnius-airport.lt/en/ https://en.wikipedia.org/wiki/Vilnius_International_Airport
105 3972 LATI large_airport Tirana International Airport Mother Teresa 41.4147 19.7206 126 EU AL AL-02 Rinas yes LATI TIA LATI http://www.tirana-airport.com/ https://en.wikipedia.org/wiki/Tirana_International_Airport_N%C3%ABn%C3%AB_Tereza Rinas International Airport, Aeroporti Nënë Tereza
106 3974 LBBG large_airport Burgas Airport 42.569917 27.515173 135 EU BG BG-02 Burgas yes LBBG BOJ LBBG https://en.wikipedia.org/wiki/Burgas_Airport Bourgas
107 3976 LBPD large_airport Plovdiv International Airport 42.067799 24.8508 597 EU BG BG-16 Plovdiv yes LBPD PDV LBPD http://www.plovdivairport.com/ https://en.wikipedia.org/wiki/Plovdiv_International_Airport
108 3977 LBSF large_airport Sofia Airport 42.696357 23.417671 1742 EU BG BG-22 Sofia yes LBSF SOF LBSF http://sofia-airport.bg https://en.wikipedia.org/wiki/Sofia_Airport Sofia International Airport, Vrazhdebna Air Base
109 3979 LBWN large_airport Varna Airport 43.232101 27.8251 230 EU BG BG-03 Varna yes LBWN VAR LBWN https://en.wikipedia.org/wiki/Varna_Airport Aksakovo
110 3984 LDDU large_airport Dubrovnik Ruđer Bošković Airport 42.562247 18.265543 527 EU HR HR-19 Dubrovnik yes LDDU DBV LDDU https://www.airport-dubrovnik.hr/en https://en.wikipedia.org/wiki/Dubrovnik_Airport Močići Airport, Zračna luka Ruđer Bošković Dubrovnik
111 3987 LDPL large_airport Pula Airport 44.893501 13.9222 274 EU HR HR-18 Pula yes LDPL PUY LDPL https://airport-pula.hr/en/ https://en.wikipedia.org/wiki/Pula_Airport
112 3989 LDRI large_airport Rijeka Airport 45.216376 14.57085 278 EU HR HR-08 Rijeka(Omišalj) yes LDRI RJK LDRI https://www.rijeka-airport.hr/ https://en.wikipedia.org/wiki/Rijeka_Airport Reka, Rika, Fiume, Omišalj
113 3991 LDSP large_airport Split Saint Jerome Airport 43.538898 16.298 79 EU HR HR-17 Split yes LDSP SPU LDSP https://split-airport.hr https://en.wikipedia.org/wiki/Split_Airport
114 3993 LDZA large_airport Zagreb Franjo Tuđman International Airport 45.742901 16.0688 353 EU HR HR-01 Velika Gorica yes LDZA ZAG LDZA https://www.zagreb-airport.hr/en https://en.wikipedia.org/wiki/Zagreb_Airport Pleso
115 3994 LDZD large_airport Zadar Airport 44.096986 15.353565 289 EU HR HR-13 Zadar yes LDZD ZAD LDZD https://www.zadar-airport.hr/en https://en.wikipedia.org/wiki/Zadar_Airport
116 3997 LEAL large_airport Alicante-Elche Miguel Hernández Airport 38.2822 -0.558156 142 EU ES ES-VC Alicante yes LEAL ALC LEAL https://www.aena.es/en/alicante-elche.html https://en.wikipedia.org/wiki/Alicante–Elche_Miguel_Hernández_Airport
117 3999 LEAS large_airport Asturias Airport 43.563599 -6.03462 416 EU ES ES-AS Ranón yes LEAS OVD LEAS https://www.aena.es/en/asturias.html https://en.wikipedia.org/wiki/Asturias_Airport
118 4001 LEBB large_airport Bilbao Airport 43.301102 -2.91061 138 EU ES ES-PV Bilbao yes LEBB BIO LEBB https://www.aena.es/en/bilbao.html https://en.wikipedia.org/wiki/Bilbao_Airport
119 4004 LEBL large_airport Josep Tarradellas Barcelona-El Prat Airport 41.2971 2.07846 12 EU ES ES-CT Barcelona yes LEBL BCN LEBL https://www.aena.es/en/josep-tarradellas-barcelona-el-prat.html https://en.wikipedia.org/wiki/Barcelona%E2%80%93El_Prat_Airport El Prat Airport,Barcelona International,Josep Tarradellas Barcelona-El Prat Airport
120 4010 LEGE large_airport Girona-Costa Brava Airport 41.904639 2.761774 468 EU ES ES-CT Girona yes LEGE GRO LEGE https://www.aena.es/en/girona-costa-brava.html https://en.wikipedia.org/wiki/Girona-Costa_Brava_Airport Girona, Costa Brava, Girona-Costa Brava
121 4013 LEIB large_airport Ibiza Airport 38.872898 1.37312 24 EU ES ES-IB Ibiza (Eivissa) yes LEIB IBZ LEIB https://www.aena.es/en/ibiza.html https://en.wikipedia.org/wiki/Ibiza_Airport
122 4019 LEMD large_airport Adolfo Suárez Madrid–Barajas Airport 40.493407 -3.572249 1998 EU ES ES-MD Madrid yes LEMD MAD LEMD https://www.aena.es/en/adolfo-suarez-madrid-barajas.html https://en.wikipedia.org/wiki/Adolfo_Su%C3%A1rez_Madrid%E2%80%93Barajas_Airport Leganés, Madrid Barajas International Airport
123 4020 LEMG large_airport Málaga-Costa del Sol Airport 36.6749 -4.49911 53 EU ES ES-AN Málaga yes LEMG AGP LEMG https://www.aena.es/en/malaga-costa-del-sol.html https://en.wikipedia.org/wiki/M%C3%A1laga_Airport Costa del Sol, Malaga
124 4021 LEMH large_airport Menorca Airport 39.862598 4.21865 302 EU ES ES-IB Mahón (Maó) yes LEMH MAH LEMH https://www.aena.es/en/menorca.html https://en.wikipedia.org/wiki/Menorca_Airport Menorca
125 308134 LEMI large_airport Region of Murcia International Airport 37.802852 -1.124884 644 EU ES ES-MC Corvera yes LEMI RMU LEMI https://www.aena.es/en/internacional-region-de-murcia.html https://en.wikipedia.org/wiki/Murcia-Corvera_Airport
126 4035 LEPA large_airport Palma de Mallorca Airport 39.551701 2.73881 27 EU ES ES-IB Palma de Mallorca yes LEPA PMI LEPA https://www.aena.es/en/palma-de-mallorca.html https://en.wikipedia.org/wiki/Son_Sant_Joan_Airport Son Sant Joan Airport, LESJ
127 4031 LERS large_airport Reus Airport 41.147509 1.168354 233 EU ES ES-CT Reus yes LERS REU LERS https://www.aena.es/en/reus.html https://en.wikipedia.org/wiki/Reus_Airport
128 4038 LEST large_airport Santiago-Rosalía de Castro Airport 42.896301 -8.41514 1213 EU ES ES-GA Santiago de Compostela yes LEST SCQ LEST https://www.aena.es/en/santiago-rosalia-de-castro.html https://en.wikipedia.org/wiki/Santiago_de_Compostela_Airport
129 4042 LEVC large_airport Valencia Airport 39.489162 -0.480961 240 EU ES ES-VC Valencia yes LEVC VLC LEVC https://www.aena.es/en/valencia.html https://en.wikipedia.org/wiki/Valencia_Airport
130 4048 LEZG large_airport Zaragoza Airport 41.666199 -1.04155 863 EU ES ES-AR Zaragoza yes LEZG ZAZ LEZG https://www.aena.es/en/zaragoza.html https://en.wikipedia.org/wiki/Zaragoza_Airport Zaragoza Air Base
131 4049 LEZL large_airport Seville Airport 37.417999 -5.89311 112 EU ES ES-AN Seville yes LEZL SVQ LEZL https://www.aena.es/en/seville.html https://en.wikipedia.org/wiki/Seville_Airport Sevilla, Sevilla Airport
132 4060 LFBD large_airport Bordeaux–Mérignac Airport 44.82865 -0.715356 162 EU FR FR-NAQ Bordeaux yes LFBD BOD LFBD https://www.bordeaux.aeroport.fr/ https://en.wikipedia.org/wiki/Bordeaux%E2%80%93M%C3%A9rignac_Airport
133 4070 LFBO large_airport Toulouse-Blagnac Airport 43.629101 1.36382 499 EU FR FR-OCC Toulouse/Blagnac yes LFBO TLS LFBO http://www.toulouse.aeroport.fr/ https://en.wikipedia.org/wiki/Toulouse_Blagnac_International_Airport Airbus
134 4123 LFKB large_airport Bastia-Poretta International airport 42.5527 9.48373 26 EU FR FR-COR Bastia yes LFKB BIA LFKB https://bastia-aeroport.cci.corsica/ https://en.wikipedia.org/wiki/Bastia_-_Poretta_Airport
135 4125 LFKF large_airport Figari Sud-Corse Airport 41.50185 9.097092 85 EU FR FR-COR Figari yes LFKF FSC LFKF https://figari-aeroport.cci.corsica/ https://en.wikipedia.org/wiki/Figari_Sud-Corse_Airport
136 4132 LFLC large_airport Clermont-Ferrand Auvergne airport 45.786701 3.16917 1090 EU FR FR-ARA Clermont-Ferrand yes LFLC CFE LFLC https://www.clermont-aeroport.com/en https://en.wikipedia.org/wiki/Clermont-Ferrand_Auvergne_Airport
137 4137 LFLL large_airport Lyon Saint-Exupéry Airport 45.725996 5.090139 821 EU FR FR-ARA Colombier-Saugnieu, Rhône yes LFLL LYS LFLL https://www.lyonaeroports.com/ https://en.wikipedia.org/wiki/Lyon%E2%80%93Saint-Exup%C3%A9ry_Airport Lyon, Saint-Exupéry, Satolas
138 4155 LFML large_airport Marseille Provence Airport 43.438088 5.2125 74 EU FR FR-PAC Marignane, Bouches-du-Rhône yes LFML MRS LFML https://www.marseille.aeroport.fr/ https://en.wikipedia.org/wiki/Marseille_Provence_Airport
139 4156 LFMN large_airport Nice-Côte d'Azur Airport 43.658401 7.21587 12 EU FR FR-PAC Nice, Alpes-Maritimes yes LFMN NCE LFMN https://www.nice.aeroport.fr/en https://en.wikipedia.org/wiki/C%C3%B4te_d'Azur_International_Airport
140 4161 LFMT large_airport Montpellier-Méditerranée Airport 43.576199 3.96301 17 EU FR FR-OCC Montpellier/Méditerranée yes LFMT MPL LFMT https://www.montpellier.aeroport.fr/ https://en.wikipedia.org/wiki/Montpellier_-_M%C3%A9diterran%C3%A9e_Airport
141 4169 LFOB large_airport Beauvais-Tillé airport 49.454399 2.11278 359 EU FR FR-HDF Beauvais yes LFOB BVA LFOB https://www.aeroportparisbeauvais.com/ https://en.wikipedia.org/wiki/Paris_Beauvais_Till%C3%A9_Airport Beauvais-Tillé
142 4185 LFPG large_airport Charles de Gaulle International Airport 49.00896 2.554117 392 EU FR FR-IDF Paris (Roissy-en-France, Val-d'Oise) yes LFPG CDG LFPG http://www.aeroportsdeparis.fr/ https://en.wikipedia.org/wiki/Charles_de_Gaulle_Airport PAR, Aéroport Roissy-Charles de Gaulle, Roissy Airport
143 4202 LFQQ large_airport Lille Airport 50.566564 3.102429 157 EU FR FR-HDF Lesquin yes LFQQ LIL LFQQ https://www.lille.aeroport.fr/home/ https://en.wikipedia.org/wiki/Lille_Airport Aéroport de Lille. Lille-Lesquin Airport
144 4206 LFRB large_airport Brest Bretagne airport 48.447899 -4.41854 325 EU FR FR-BRE Brest yes LFRB BES LFRB https://www.brest.aeroport.bzh https://en.wikipedia.org/wiki/Brest_Bretagne_Airport
145 4221 LFRS large_airport Nantes Atlantique Airport 47.153198 -1.61073 90 EU FR FR-PDL Nantes yes LFRS NTE LFRS https://www.nantes.aeroport.fr/en https://en.wikipedia.org/wiki/Nantes_Atlantique_Airport
146 4226 LFSB large_airport EuroAirport Basel–Mulhouse–Freiburg 47.60068 7.521117 885 EU FR FR-GES Bâle / Mulhouse yes LFSB BSL LFSB http://www.euroairport.com/ https://en.wikipedia.org/wiki/EuroAirport_Basel-Mulhouse-Freiburg MLH, EAP, LSZM, Bâle, Basle
147 4238 LFST large_airport Strasbourg Airport 48.5383 7.62823 505 EU FR FR-GES Strasbourg yes LFST SXB LFST http://www.strasbourg.aeroport.fr/E/index.php https://en.wikipedia.org/wiki/Strasbourg_Airport BA 124
148 4251 LGAV large_airport Athens Eleftherios Venizelos International Airport 37.936401 23.9445 308 EU GR GR-I Spata-Artemida yes LGAV ATH LGAV https://www.aia.gr https://en.wikipedia.org/wiki/Athens_International_Airport
149 4258 LGIR large_airport Heraklion International Nikos Kazantzakis Airport 35.339699 25.1803 115 EU GR GR-M Heraklion yes LGIR HER LGIR https://www.heraklion-airport.gr/en/ https://en.wikipedia.org/wiki/Heraklion_International_Airport_"Nikos_Kazantzakis" Crete Island
150 4266 LGKR large_airport Corfu Ioannis Kapodistrias International Airport 39.60145 19.912179 6 EU GR GR-F Kerkyra (Corfu) yes LGKR CFU LGKR https://www.cfu-airport.gr/en https://en.wikipedia.org/wiki/Corfu_International_Airport Corfu
151 4268 LGKV large_airport Kavala Alexander the Great International Airport 40.9133 24.6192 18 EU GR GR-A Kavala yes LGKV KVA LGKV https://www.kva-airport.gr/en https://en.wikipedia.org/wiki/Kavala_International_Airport Megas Alexandros
152 4281 LGSA large_airport Chania International Airport 35.531207 24.150673 490 EU GR GR-M Souda yes LGSA CHQ LGSA https://en.wikipedia.org/wiki/Chania_International_Airport
153 4286 LGSR large_airport Santorini International Airport 36.400045 25.478638 127 EU GR GR-L Santorini Island yes LGSR JTR LGSR https://www.jtr-airport.gr/ https://en.wikipedia.org/wiki/Santorini_(Thira)_National_Airport
154 4293 LGTS large_airport Thessaloniki Macedonia International Airport 40.51928 22.970009 22 EU GR GR-B Thessaloniki yes LGTS SKG LGTS https://www.skg-airport.gr/en https://en.wikipedia.org/wiki/Thessaloniki_Airport Makedonia. Macedonia. Salonica
155 4296 LHBP large_airport Budapest Liszt Ferenc International Airport 47.43018 19.262393 495 EU HU HU-BU Budapest yes LHBP BUD LHBP http://www.bud.hu/english https://en.wikipedia.org/wiki/Budapest_Ferenc_Liszt_International_Airport Ferihegyi nemzetközi repülőtér, Budapest Liszt Ferenc international Airport
156 4297 LHDC large_airport Debrecen International Airport 47.489469 21.616278 359 EU HU HU-HB Debrecen yes LHDC DEB LHDC http://www.airportdebrecen.hu/ https://en.wikipedia.org/wiki/Debrecen_International_Airport Debreceni nemzetközi repülőtér
157 4302 LHPP large_airport Pécs-Pogány International Airport 45.988891 18.242044 651 EU HU HU-BA Pécs yes LHPP PEV LHPP http://www.airportpecs.hu/ https://en.wikipedia.org/wiki/P%C3%A9cs-Pog%C3%A1ny_International_Airport Pecs South Airport, QPJ
158 4310 LIBD large_airport Bari Karol Wojtyła International Airport 41.138901 16.760599 193 EU IT IT-75 Bari yes LIBD BRI LIBD BA02 http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=25 https://en.wikipedia.org/wiki/Bari_Karol_Wojty%C5%82a_Airport Bari "Karol Wojtyla" International Airport, Palese Macchie Airport, Palese Airport
159 4314 LIBP large_airport Abruzzo Airport 42.431079 14.182981 48 EU IT IT-65 Pescara yes LIBP PSR LIBP PE01 http://www.abruzzo-airport.it/eng/index.php https://en.wikipedia.org/wiki/Abruzzo_Airport Pescara, Abruzzo Pasquale Liberi International
160 4315 LIBR large_airport Brindisi Airport 40.6576 17.947001 47 EU IT IT-75 Brindisi yes LIBR BDS LIBR BR03 http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=29 https://en.wikipedia.org/wiki/Brindisi_Airport Brindisi Papola Casale Airport, Brindisi Salento Airport, Brindisi Casale Antonio Papolo Airport, Salento Airport
161 4317 LICA large_airport Lamezia Terme Sant'Eufemia International Airport 38.906214 16.246007 39 EU IT IT-78 Lamezia Terme (CZ) yes LICA SUF LICA CZ02 https://en.wikipedia.org/wiki/Lamezia_Terme_International_Airport
162 4318 LICC large_airport Catania-Fontanarossa Airport 37.466801 15.0664 39 EU IT IT-82 Catania yes LICC CTA LICC CT03 http://www.aeroporto.catania.it/ https://en.wikipedia.org/wiki/Catania-Fontanarossa_Airport Lanza Di Trabie, Catania-Fontanarossa Vincenzo Bellini
163 4321 LICJ large_airport Falcone–Borsellino Airport 38.175999 13.091 65 EU IT IT-82 Palermo yes LICJ PMO LICJ PA03 http://www.gesap.it/index.php https://en.wikipedia.org/wiki/Falcone%E2%80%93Borsellino_Airport Palermo Airport, Punta Raisi Airport
164 4332 LIEE large_airport Cagliari Elmas Airport 39.251499 9.05428 13 EU IT IT-88 Cagliari yes LIEE CAG LIEE CA07 http://www.cagliariairport.it/en/ https://en.wikipedia.org/wiki/Cagliari_Elmas_Airport Cagliari Airport,Elmas
165 4333 LIEO large_airport Olbia Costa Smeralda Airport 40.898953 9.518457 37 EU IT IT-88 Olbia (SS) yes LIEO OLB LIEO SS03 https://www.geasar.it/en https://en.wikipedia.org/wiki/Olbia_Costa_Smeralda_Airport
166 4340 LIMC large_airport Milan Malpensa International Airport 45.6306 8.72811 768 EU IT IT-25 Ferno (VA) yes LIMC MXP LIMC MI12 http://www.sea-aeroportimilano.it/en/malpensa/index.phtml https://en.wikipedia.org/wiki/Malpensa_Airport MIL, Silvio Berlusconi
167 4342 LIMF large_airport Turin Airport 45.200802 7.64963 989 EU IT IT-21 Caselle Torinese (TO) yes LIMF TRN LIMF TO11 http://www.aeroportoditorino.it/ https://en.wikipedia.org/wiki/Turin_International_Airport Torino-Caselle Airport, Sandro Pertini Airport, Turin-Caselle Airport
168 4344 LIMJ large_airport Genoa Cristoforo Colombo Airport 44.412039 8.840732 13 EU IT IT-42 Genova (GE) yes LIMJ GOA LIMJ GE01 http://www.aeroportodigenova.com/en/home/index.php https://en.wikipedia.org/wiki/Genoa_Cristoforo_Colombo_Airport Genova Sestri, Sestri Cristoforo Colombo Airport, Cristoforo Colombo Airport
169 4354 LIPE large_airport Bologna Guglielmo Marconi Airport 44.5354 11.2887 123 EU IT IT-45 Bologna yes LIPE BLQ LIPE BO08 http://www.bologna-airport.it/?LN=UK https://en.wikipedia.org/wiki/Bologna_Airport Guglielmo Marconi Airport, Borgo Panigale Airport
170 4361 LIPQ large_airport Trieste Airport 45.827862 13.466672 39 EU IT IT-36 Ronchi dei Legionari/Trieste yes LIPQ TRS LIPQ GO02 http://www.triesteairport.it https://en.wikipedia.org/wiki/Trieste_%E2%80%93_Friuli_Venezia_Giulia_Airport Ronchi Dei Legionari Airport, Trieste-Ronchi dei Legionari Airport
171 4362 LIPR large_airport Federico Fellini International Airport 44.020024 12.612198 40 EU IT IT-45 Rimini (RN) yes LIPR RMI LIPR RN01 http://www.riminiairport.com/english/ https://en.wikipedia.org/wiki/Federico_Fellini_International_Airport Rimini Miramare Airport, Rimini San Marino International Airport
172 4366 LIPX large_airport Verona Villafranca Valerio Catullo Airport 45.394955 10.887303 239 EU IT IT-34 Caselle (VR) yes LIPX VRN LIPX VR10 http://www.aeroportoverona.it/ https://en.wikipedia.org/wiki/Verona_Airport Valerio Catullo, Villafranca International Airport, Valerio Catullo Airport, Villafranca Airport
173 4368 LIPZ large_airport Venice Marco Polo Airport 45.505299 12.3519 7 EU IT IT-34 Venezia (VE) yes LIPZ VCE LIPZ VE05 http://www.veniceairport.com/core/index.jsp?_requestid=37300&language=en https://en.wikipedia.org/wiki/Venice_Marco_Polo_Airport Venezia-Tessera, Venice-Tessera
174 4372 LIRF large_airport Rome–Fiumicino Leonardo da Vinci International Airport 41.804532 12.251998 13 EU IT IT-62 Rome yes LIRF FCO LIRF RM11 http://www.adr.it/portal/portal/adr/Fiumicino/Leonardo_da_vinci/Header_Window?action=2 https://en.wikipedia.org/wiki/Rome_Fiumicino_Airport ROM, Rome Fiumicino Airport, Fiumicino Airport
175 4378 LIRN large_airport Naples International Airport 40.886002 14.2908 294 EU IT IT-72 Napoli yes LIRN NAP LIRN NA01 http://www.portal.gesac.it/ https://en.wikipedia.org/wiki/Naples_International_Airport Capodichino Airport, Aeroporto Internazionale di Napoli-Capodichino Ugo Niutta, Ariopuorto 'nternazziunale 'e Napule-Capodichino Ugo Niutta
176 4379 LIRP large_airport Pisa International Airport 43.683899 10.3927 6 EU IT IT-52 Pisa (PI) yes LIRP PSA LIRP PI05 http://www.pisa-airport.com/ https://en.wikipedia.org/wiki/Pisa_International_Airport Galileo Galilei Airport, San Giusto Airport
177 4380 LIRQ large_airport Florence Airport, Peretola 43.808558 11.202822 142 EU IT IT-52 Firenze (FI) yes LIRQ FLR LIRQ FI04 http://www.aeroporto.firenze.it/ https://en.wikipedia.org/wiki/Florence_Airport Firenze, Amerigo Vespucci Airport, Aeroporto di Firenze-Peretola
178 4384 LIRZ large_airport Perugia San Francesco d'Assisi – Umbria International Airport 43.095901 12.5132 697 EU IT IT-55 Perugia (PG) yes LIRZ PEG LIRZ PG10 http://www.airport.umbria.it/ https://en.wikipedia.org/wiki/Perugia_San_Francesco_d%27Assisi_%E2%80%93_Umbria_International_Airport Perugia San Francesco d'Assisi, Perugia Sant'Egidio Airport, Umbria International Airport
179 4386 LJLJ large_airport Ljubljana Jože Pučnik Airport 46.223701 14.4576 1273 EU SI SI-012 Zgornji Brnik yes LJLJ LJU LJLJ http://www.lju-airport.si/ https://en.wikipedia.org/wiki/Ljubljana_Jo%C5%BEe_Pu%C4%8Dnik_Airport Ljubljana, Kranj
180 4393 LKCS large_airport České Budějovice South Bohemian Airport 48.948171 14.42832 1417 EU CZ CZ-JC České Budějovice yes LKCS JCL LKCS https://www.airport-cb.cz/ https://en.wikipedia.org/wiki/%C4%8Cesk%C3%A9_Bud%C4%9Bjovice_Airport Jihočeské letiště České Budějovice
181 4399 LKKV large_airport Karlovy Vary Airport 50.202999 12.915 1989 EU CZ CZ-KA Karlovy Vary yes LKKV KLV LKKV http://www.airport-k-vary.cz/indexEN.php https://en.wikipedia.org/wiki/Karlovy_Vary_Airport Karlovy Vary International
182 4402 LKMT large_airport Leoš Janáček Airport Ostrava 49.696301 18.111099 844 EU CZ CZ-MO Mošnov yes LKMT OSR LKMT http://www.airport-ostrava.cz/en/ https://en.wikipedia.org/wiki/Ostrava_Leos_Janacek_Airport Ostrava-Mošnov International Airport
183 4405 LKPD large_airport Pardubice Airport 50.015049 15.73981 741 EU CZ CZ-PA Pardubice yes LKPD PED LKPD http://www.airport-pardubice.cz/?lang=en https://en.wikipedia.org/wiki/Pardubice_Airport Letiště Pardubice
184 4408 LKPR large_airport Václav Havel Airport Prague 50.100874 14.259911 1247 EU CZ CZ-PR Prague yes LKPR PRG LKPR http://www.prg.aero https://en.wikipedia.org/wiki/Václav_Havel_Airport_Prague Letiště Praha-Ruzyně, Ruzyně International Airport, Letiště Václava Havla Praha
185 4427 LMML large_airport Malta International Airport 35.845932 14.491546 300 EU MT MT-25 Valletta yes LMML MLA LMML https://www.maltairport.com/ https://en.wikipedia.org/wiki/Malta_International_Airport Luqa Airport, Valletta, Gudja, RAF Luqa
186 4430 LOWG large_airport Graz Airport 46.9911 15.4396 1115 EU AT AT-6 Feldkirchen bei Graz yes LOWG GRZ LOWG http://www.flughafen-graz.at/ https://en.wikipedia.org/wiki/Graz_Airport Thalerhof Airport, Fliegerhorst Nittner, Nittner Air Base, Christophorus 12
187 4431 LOWI large_airport Innsbruck Airport 47.260201 11.344 1907 EU AT AT-7 Innsbruck yes LOWI INN LOWI http://www.innsbruck-airport.com/ https://en.wikipedia.org/wiki/Innsbruck_Airport Kranebitten Airport
188 4435 LOWK large_airport Klagenfurt Airport 46.642502 14.3377 1472 EU AT AT-2 Klagenfurt am Wörthersee yes LOWK KLU LOWK http://www.klagenfurt-airport.com/ https://en.wikipedia.org/wiki/Klagenfurt_Airport Kärnten Airport, Christophorus 11
189 4432 LOWL large_airport Linz-Hörsching Airport 48.235362 14.188128 980 EU AT AT-4 Linz yes LOWL LNZ LOWL http://www.flughafen-linz.at/ https://en.wikipedia.org/wiki/Linz_Airport Blue Danube Airport, Vogler Air Base, Fliegerhorst Vogler, Christophorus 10
190 4433 LOWS large_airport Salzburg Airport 47.793301 13.0043 1411 EU AT AT-5 Salzburg yes LOWS SZG LOWS http://www.salzburg-airport.com/ https://en.wikipedia.org/wiki/Salzburg_Airport Christophorus 6
191 4434 LOWW large_airport Vienna International Airport 48.110298 16.5697 600 EU AT AT-9 Vienna yes LOWW VIE LOWW http://www.viennaairport.com/en/ https://en.wikipedia.org/wiki/Vienna_International_Airport
192 4448 LPFR large_airport Faro - Gago Coutinho International Airport 37.015909 -7.970939 24 EU PT PT-08 Faro yes LPFR FAO LPFR http://faroairport.pt https://en.wikipedia.org/wiki/Faro_Airport_(Portugal)
193 4452 LPMA large_airport Cristiano Ronaldo International Airport 32.697812 -16.774613 192 EU PT PT-30 Funchal yes LPMA FNC LPMA http://www.aeroportomadeira.pt https://en.wikipedia.org/wiki/Madeira_Airport Madeira, Funchal
194 4456 LPPD large_airport João Paulo II Airport 37.7411994934 -25.6979007721 259 EU PT PT-20 Ponta Delgada yes LPPD PDL LPPD http://www.ana.pt/en-US/Aeroportos/acores/PontaDelgada/Pages/Homepage-PontaDelgada.aspx https://en.wikipedia.org/wiki/Jo%C3%A3o_Paulo_II_Airport
195 4459 LPPR large_airport Francisco de Sá Carneiro Airport 41.2481002808 -8.68138980865 228 EU PT PT-13 Porto yes LPPR OPO LPPR http://www.ana.pt/en-US/Aeroportos/porto/Porto/Pages/Homepage-Porto.aspx https://en.wikipedia.org/wiki/Francisco_S%C3%A1_Carneiro_Airport
196 4461 LPPT large_airport Lisbon Humberto Delgado Airport 38.7813 -9.13592 374 EU PT PT-11 Lisbon yes LPPT LIS LPPT https://lisbonairport.pt https://en.wikipedia.org/wiki/Lisbon_Airport Lisboa, Portela
197 4467 LQBK large_airport Banja Luka International Airport 44.941399 17.297501 400 EU BA BA-SRP Mahovljani yes LQBK BNX LQBK http://www.banjaluka-airport.com/ https://en.wikipedia.org/wiki/Banja_Luka_International_Airport Mahovljani Airport
198 4468 LQMO large_airport Mostar International Airport 43.282489 17.846101 156 EU BA BA-BIH Mostar yes LQMO OMO LQMO http://www.mostar-airport.ba/ https://en.wikipedia.org/wiki/Mostar_International_Airport
199 4469 LQSA large_airport Sarajevo International Airport 43.8246 18.331499 1708 EU BA BA-BIH Sarajevo yes LQSA SJJ LQSA http://www.sarajevo-airport.ba/ https://en.wikipedia.org/wiki/Sarajevo_International_Airport
200 4470 LQTZ large_airport Tuzla International Airport 44.459858 18.723627 784 EU BA BA-BIH Dubrave Gornje yes LQTZ TZL LQTZ http://www.tuzla-airport.ba/ https://en.wikipedia.org/wiki/Tuzla_International_Airport
201 4472 LRBC large_airport Bacău George Enescu International Airport 46.5219 26.910299 607 EU RO RO-BC Bacău yes LRBC BCM LRBC http://www.bacauairport.ro/ https://en.wikipedia.org/wiki/Bac%C4%83u_International_Airport Bacau, RoAF 95th Air Base
202 333670 LRBV large_airport Brașov-Ghimbav International Airport 45.70558 25.522871 1740 EU RO RO-BV Brașov (Ghimbav) yes LRBV GHV LRBV https://brasovairport.ro/ https://en.wikipedia.org/wiki/Brașov-Ghimbav_International_Airport Aeroportul Internațional Brașov-Ghimbav
203 4475 LRCK large_airport Mihail Kogălniceanu International Airport 44.362202 28.4883 353 EU RO RO-CT Constanța yes LRCK CND LRCK http://www.mk-airport.ro/ https://en.wikipedia.org/wiki/Mihail_Kog%C4%83lniceanu_International_Airport Constanța, Mihail Kogalniceanu, RoAF 57th Air Base
204 4476 LRCL large_airport Avram Iancu Cluj International Airport 46.786042 23.685733 1039 EU RO RO-CJ Cluj-Napoca yes LRCL CLJ LRCL http://www.airportcluj.ro/ https://en.wikipedia.org/wiki/Cluj_International_Airport Someşeni Airport
205 4479 LRCV large_airport Craiova International Airport 44.3181 23.888599 626 EU RO RO-DJ Craiova yes LRCV CRA LRCV http://www.aeroportcraiova.ro/ https://en.wikipedia.org/wiki/Craiova_International_Airport
206 4480 LRIA large_airport Iaşi International Airport 47.179633 27.621431 411 EU RO RO-IS Iaşi yes LRIA IAS LRIA https://www.aeroport-iasi.ro/ https://en.wikipedia.org/wiki/Ia%C5%9Fi_International_Airport Iasi, Jassy
207 4481 LROD large_airport Oradea International Airport 47.025299 21.9025 465 EU RO RO-BH Oradea yes LROD OMR LROD https://www.aeroportoradea.ro/ https://en.wikipedia.org/wiki/Oradea_International_Airport
208 4482 LROP large_airport Bucharest Henri Coandă International Airport 44.571792 26.103285 314 EU RO RO-IF Otopeni yes LROP OTP LROP http://www.bucharestairports.ro/en/ https://en.wikipedia.org/wiki/Henri_Coand%C4%83_International_Airport BUH, Otopeni Airport, RoAF 90th Airlift Base
209 4483 LRSB large_airport Sibiu International Airport 45.785841 24.086666 1496 EU RO RO-SB Sibiu yes LRSB SBZ LRSB http://www.sibiuairport.ro/ https://en.wikipedia.org/wiki/Sibiu_International_Airport
210 4485 LRSV large_airport Suceava Ștefan cel Mare International Airport 47.6875 26.354099 1375 EU RO RO-SV Suceava yes LRSV SCV LRSV http://www.aeroportsuceava.ro/index_en.html https://en.wikipedia.org/wiki/Suceava_Airport
211 4488 LRTR large_airport Timișoara Traian Vuia International Airport 45.809898 21.3379 348 EU RO RO-TM Timişoara yes LRTR TSR LRTR http://www.aerotim.ro/ https://en.wikipedia.org/wiki/Traian_Vuia_International_Airport RoAF 93rd Air Base, Giarmata Airport, Temesvár, Temeschburg, Temeswar, Temeschwar, Тимишоара, Темишвар, Temišvar, Banat
212 4490 LSGG large_airport Geneva International Airport 46.238098 6.10895 1411 EU CH CH-GE Geneva yes LSGG GVA LSGG https://www.gva.ch/ https://en.wikipedia.org/wiki/Geneva_Airport Cointrin Airport
213 4505 LSZH large_airport Zürich Airport 47.458056 8.548056 1417 EU CH CH-ZH Zurich yes LSZH ZRH LSZH http://www.zurich-airport.com/ https://en.wikipedia.org/wiki/Zurich_Airport
214 317457 LTFM large_airport İstanbul Airport 41.274874 28.732136 325 EU TR TR-34 Istanbul yes LTFM IST LTFM https://www.istairport.com/ https://en.wikipedia.org/wiki/Istanbul_Airport Arnavutköy
215 4571 LUKK large_airport Chişinău International Airport 46.92774 28.931704 399 EU MD MD-CU Chişinău yes LUKK RMO LUKK http://www.airport.md/ https://en.wikipedia.org/wiki/Chi%C5%9Fin%C4%83u_International_Airport KIV
216 4572 LWOH large_airport Ohrid St. Paul the Apostle Airport 41.18 20.7423 2313 EU MK MK-003 Ohrid yes LWOH OHD LWOH https://ohd.airports.com.mk/ https://en.wikipedia.org/wiki/Ohrid_Airport
217 4573 LWSK large_airport Skopje International Airport 41.958087 21.622581 781 EU MK MK-008 Ilinden yes LWSK SKP LWSK http://skp.airports.com.mk/ https://en.wikipedia.org/wiki/Skopje_Airport Petrovec
218 4574 LXGB large_airport Gibraltar Airport 36.151679 -5.34978 12 EU GI GI-U-A Gibraltar yes LXGB GIB LXGB https://www.gibraltarairport.gi/ https://en.wikipedia.org/wiki/Gibraltar_Airport
219 4610 LYBE large_airport Belgrade Nikola Tesla Airport 44.818401 20.309099 335 EU RS RS-00 Belgrade yes LYBE BEG LYBE http://www.beg.aero/ https://en.wikipedia.org/wiki/Belgrade_Nikola_Tesla_Airport Beograd, Surčin Airport
220 4612 LYNI large_airport Niš Constantine the Great Airport 43.336538 21.856242 648 EU RS RS-20 Niš yes LYNI INI LYNI https://ini.aerodromisrbije.rs/en/ https://en.wikipedia.org/wiki/Ni%C5%A1_Constantine_the_Great_Airport Aerodrom Konstantin Veliki Niš
221 4613 LYPG large_airport Podgorica Airport / Podgorica Golubovci Airbase 42.359402 19.2519 141 EU ME ME-16 Podgorica yes LYPG TGD LYPG https://montenegroairports.com/en/podgorica-airport/ https://en.wikipedia.org/wiki/Podgorica_Airport Podgorica Airbase, Golubovci Airbase, Аеродром Подгорица, Aerodrom Podgorica
222 4617 LZIB large_airport M. R. Štefánik Airport 48.1702 17.2127 436 EU SK SK-BL Bratislava yes LZIB BTS LZIB https://en.wikipedia.org/wiki/M._R._%C5%A0tef%C3%A1nik_Airport
223 6467 UKBB large_airport Boryspil International Airport 50.345001 30.894699 427 EU UA UA-32 Boryspil no UKBB KBP UKBB https://kbp.aero/en/ https://en.wikipedia.org/wiki/Boryspil_International_Airport Borispol, Міжнародний аеропорт "Бориспіль", Kyiv
224 6474 UKFF large_airport Simferopol International Airport 45.0522 33.975101 639 EU UA UA-43 Simferopol no UKFF SIP UKFF URFF https://new.sipaero.ru/ https://en.wikipedia.org/wiki/Simferopol_International_Airport
225 6481 UKLL large_airport Lviv International Airport 49.8125 23.9561 1071 EU UA UA-46 Lviv no UKLL LWO UKLL http://www.airport.lviv.ua/index.php?id=home&L=1 https://en.wikipedia.org/wiki/Lviv_International_Airport Міжнародний аеропорт "Львів"
226 6487 UKOO large_airport Odesa International Airport 46.427196 30.672649 172 EU UA UA-51 Odesa no UKOO ODS https://www.airport.od.ua/ https://en.wikipedia.org/wiki/Odessa_International_Airport ODS, UKOO, УКОО, Odesa Central, Міжнародний аеропорт Одеса, Odessa Airport
227 6489 ULLI large_airport Pulkovo Airport 59.800301 30.262501 78 EU RU RU-SPE St. Petersburg yes ULLI LED ULLI https://pulkovoairport.ru/ https://en.wikipedia.org/wiki/Pulkovo_Airport Аэропо́рт Пу́лково, Leningrad, Shosseynaya Airport, Saint Petersburg, Petrograd
228 6490 ULMM large_airport Emperor Nicholas II Murmansk Airport 68.7817 32.750801 266 EU RU RU-MUR Murmansk yes ULMM MMK ULMM http://www.airport-murmansk.ru/ https://en.wikipedia.org/wiki/Murmansk_Airport Аэропо́рт Му́рманска им. Николая II or Аэропорт Мурмаши, Мурманск
229 6495 UMBB large_airport Brest International Airport 52.108138 23.89676 468 EU BY BY-BR Brest yes UMBB BQT UMBB https://www.ban.by/filialy/brest https://en.wikipedia.org/wiki/Brest_Airport Аэропорт Брест
230 6498 UMKK large_airport Khrabrovo Airport 54.89161 20.598559 42 EU RU RU-KGD Kaliningrad yes UMKK KGD UMKK https://kgdavia.ru/ https://en.wikipedia.org/wiki/Khrabrovo_Airport УМКК, Храброво
231 6501 UMMS large_airport Minsk National Airport 53.888071 28.039964 670 EU BY BY-MI Minsk yes UMMS MSQ UMMS http://airport.by/en https://en.wikipedia.org/wiki/Minsk_National_Airport Нацыянальны аэрапорт, Minsk 2 Airport
232 6507 UNNT large_airport Novosibirsk Tolmachevo Airport 55.019756 82.618675 365 EU RU RU-NVS Novosibirsk yes UNNT OVB UNNT http://eng.tolmachevo.ru/ https://en.wikipedia.org/wiki/Tolmachevo_Airport
233 6512 URKK large_airport Krasnodar Pashkovsky International Airport 45.034465 39.174215 118 EU RU RU-KDA Krasnodar yes URKK KRR URKK https://krr.aero/en/ https://en.wikipedia.org/wiki/Pashkovsky_Airport Pashkovskiy Airport, Pashkovsky Airport
234 6514 URMM large_airport Mineralnye Vody Airport 44.225101 43.081902 1054 EU RU RU-STA Mineralnyye Vody yes URMM MRV URMM https://en.wikipedia.org/wiki/Mineralnye_Vody_Airport
235 326363 URRP large_airport Platov International Airport 47.493888 39.924722 213 EU RU RU-ROS Rostov-on-Don no URRP ROV URRP https://en.wikipedia.org/wiki/Platov_International_Airport УРРП, Международный аэропорт Платов
236 6519 URSS large_airport Sochi International Airport 43.449902 39.9566 89 EU RU RU-KDA Sochi yes URSS AER URSS https://aer.aero/en https://en.wikipedia.org/wiki/Sochi_International_Airport Adler Airport, Black Sea, Международный Аэропорт Сочи
237 6522 URWW large_airport Volgograd International Airport 48.78134 44.339194 482 EU RU RU-VGG Volgograd yes URWW VOG URWW https://en.wikipedia.org/wiki/Volgograd_International_Airport Gumrak Airport, Аэропорт Волгоград, Аэропорт Гумрак
238 26395 UUDL large_airport Golden Ring Yaroslavl International Airport 57.560699 40.157398 287 EU RU RU-YAR Tunoshna yes UUDL IAR UUDL https://en.wikipedia.org/wiki/Tunoshna
239 26396 UUEE large_airport Sheremetyevo International Airport 55.976858 37.41121 622 EU RU RU-MOS Moscow yes UUEE SVO UUEE http://svo.aero/ https://en.wikipedia.org/wiki/Sheremetyevo_International_Airport УУЕЕ, Москва, MOW, Международный аэропорт Шереметьево, Alexander S. Pushkin
240 26403 UWGG large_airport Nizhny Novgorod / Strigino International Airport 56.227351 43.785152 256 EU RU RU-NIZ Nizhny Novgorod yes UWGG GOJ UWGG http://www.airportnn.ru/en/ https://en.wikipedia.org/wiki/Nizhny_Novgorod_International_Airport УВГГ, ЬВГГ, Нижний Новгород / Стригино
241 42965 UWPS large_airport Saransk International Airport 54.12513 45.212257 676 EU RU RU-MO Saransk yes UWPS SKX UWPS https://en.wikipedia.org/wiki/Saransk_Airport Аэропорт Саранск
242 26411 UWUU large_airport Ufa International Airport 54.557499 55.874401 449 EU RU RU-BA Ufa yes UWUU UFA UWUU http://www.airportufa.ru/en/ https://en.wikipedia.org/wiki/Ufa_International_Airport УВУУ, Международный аэропорт Уфа
243 26412 UWWW large_airport Kurumoch International Airport 53.504902 50.164299 477 EU RU RU-SAM Samara yes UWWW KUF UWWW https://kuf.aero/en/ https://en.wikipedia.org/wiki/Kurumoch_International_Airport УВВВ, Самара / Курумоч
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ services:
volumes: volumes:
- "./public:/app/public" - "./public:/app/public"
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds. # Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv). # Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
- "./db:/app/db" - "./db:/app/db"
environment: environment:
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and # e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
+2
View File
@@ -197,6 +197,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
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. // 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}`); 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 fly-to button in the on-board list (ISS-081: ICAO label). // 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}`);
+23 -52
View File
@@ -1,18 +1,14 @@
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.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. import { REPAIR_COST } from '../lib/game/catalog';
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers.
import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog';
// v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in // v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short // origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts // the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate // exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
// passes; shortening the gate via the hook brings it back to service on the next tick. // 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 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 HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
@@ -69,14 +65,6 @@ async function setHealth(
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);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats). // Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { 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;
@@ -88,8 +76,7 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a))); return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
} }
// Closest airport to EDDF from /api/airports — the short fixed leg for the broken-burn flight. // Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { 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 } }>;
@@ -173,8 +160,8 @@ test.afterEach(async ({ page }) => {
await restoreStarter(page); await restoreStarter(page);
}); });
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => { test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait 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 await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
@@ -188,41 +175,25 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value') await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
.toContainText('42%'); .toContainText('42%');
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel. // (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
s = await setHealth(page, { planeId: STARTER, health: 0 }); s = await setHealth(page, { planeId: STARTER, health: 0 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0); expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears') await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
.toHaveText('broken', { timeout: 15_000 }); .toHaveText('broken', { timeout: 15_000 });
// Full tank for the flight (ISS-082 hook — dispatch would only top up to 150% of the leg, not full)… // The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
await setFuelFull(page, STARTER); 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); s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!; const starter = s.planes.find((p) => p.id === STARTER)!;
expect(starter.fuel).toBe(starter.maxFuel); expect(starter.status).toBe('available'); // still grounded…
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch). // (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
const nearest = await nearestToHub(page);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200);
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for the broken plane to land at the nearest airport',
})
.toBe(nearest.icao);
// Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts).
s = await getState(page);
const landed = s.planes.find((p) => p.id === STARTER)!;
const entry = s.catalog.find((e) => e.model === starter.model)!;
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB);
const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon));
expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6);
// Wear clamped at 0 — the broken plane stays broken.
expect(landed.health).toBe(0);
// (c) Repair through the UI: exact cash drop + "in repair" pill.
const cashBefore = s.account.cash; const cashBefore = s.account.cash;
const repairBtn = page.getByTestId(`repair-${STARTER}`); const repairBtn = page.getByTestId(`repair-${STARTER}`);
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 }); await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
@@ -237,7 +208,7 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
const repaired = post.planes.find((p) => p.id === STARTER)!; const repaired = post.planes.find((p) => p.id === STARTER)!;
expect(repaired.status).toBe('repairing'); expect(repaired.status).toBe('repairing');
expect(repaired.health).toBe(100); // restored to full… 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) 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') await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
+7 -7
View File
@@ -22,7 +22,7 @@ async function waitForAirportCount(page: Page): Promise<number> {
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10); return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10);
} }
test('page loads with the map and EU airport count', async ({ page }) => { test('page loads with the map and world airport count', async ({ page }) => {
await page.goto(APP_URL); await page.goto(APP_URL);
await expect(page.getByTestId('map')).toBeVisible(); await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page); const count = await waitForAirportCount(page);
@@ -57,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
@@ -186,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
+156
View File
@@ -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,9 @@ 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; maxFuel: number;
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
@@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// 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> {
@@ -450,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
});
+9 -5
View File
@@ -6,7 +6,7 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement'; 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. // v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed'; import { STARTING_REPUTATION } from '../lib/game/seed';
// 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
@@ -27,7 +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) — feeds conditionBurnMultiplier in the auto-refuel math 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;
} }
@@ -210,6 +210,8 @@ 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);
@@ -220,6 +222,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below. // 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-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();
@@ -389,11 +393,11 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts): // Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
// wind-adjusted requirement × condition multiplier, 150% margin capped at tank capacity. // wind-adjusted requirement, 150% margin capped at tank capacity.
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon); const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB); const wHub = weatherFor(HUB);
const required = requiredFuelFor( const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health), entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg, entry.emptyWeightKg,
entry.maxFuel, entry.maxFuel,
km, km,
@@ -424,7 +428,7 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
const landed = await getState(page); const landed = await getState(page);
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo( expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health), entry.emptyWeightKg, entry.maxFuel, km, 1), 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, 6,
); );
+46
View File
@@ -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);
}
});
+12 -2
View File
@@ -495,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
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports'; import { assertUniqueIcaos, filterWorldAirports, parseCsv, toGeoJson, airportsHandler } from './airports';
const HEADER = const HEADER =
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"'; '"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
@@ -53,25 +53,34 @@ describe('parseCsv', () => {
}); });
}); });
describe('filterEuAirports', () => { describe('filterWorldAirports (v0.44.0 — world dataset, no continent check)', () => {
it('keeps EU fixed-wing airports and seaplane bases, drops the rest', () => { it('keeps fixed-wing airports and seaplane bases from every continent, drops the rest by type', () => {
const rows = parseCsv( const rows = parseCsv(
[ [
HEADER, HEADER,
row({ ident: 'EU-SMALL', type: 'small_airport' }), row({ ident: 'EU-SMALL', type: 'small_airport' }),
row({ ident: 'EU-MED', type: 'medium_airport' }), row({ ident: 'NA-LRG', continent: 'NA', type: 'large_airport' }),
row({ ident: 'EU-LRG', type: 'large_airport' }), row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
row({ ident: 'EU-SEA', type: 'seaplane_base' }), row({ ident: 'EU-SEA', type: 'seaplane_base' }),
row({ ident: 'EU-LEGACY', type: 'airport' }), row({ ident: 'EU-LEGACY', type: 'airport' }),
row({ ident: 'EU-Helio', type: 'heliport' }), row({ ident: 'NA-Helio', continent: 'NA', type: 'heliport' }),
row({ ident: 'EU-CLOSED', type: 'closed' }), row({ ident: 'AS-CLOSED', continent: 'AS', type: 'closed' }),
row({ ident: 'EU-BALLOON', type: 'balloonport' }),
row({ ident: 'NA-SMALL', continent: 'NA' }),
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
].join('\n'), ].join('\n'),
); );
const kept = filterEuAirports(rows).map((r) => r.ident); const kept = filterWorldAirports(rows).map((r) => r.ident);
expect(kept.sort()).toEqual(['EU-LEGACY', 'EU-LRG', 'EU-MED', 'EU-SEA', 'EU-SMALL']); expect(kept.sort()).toEqual(['AS-LRG', 'EU-LEGACY', 'EU-SEA', 'EU-SMALL', 'NA-LRG']);
});
});
describe('assertUniqueIcaos (v0.44.0 load-time guard)', () => {
it('passes a collection with unique ICACOs and throws on the first duplicate', () => {
const ok = toGeoJson(parseCsv([HEADER, row({ ident: 'AAAA', icao_code: 'AAAA' }), row({ ident: 'BBBB', icao_code: 'BBBB' })].join('\n')));
expect(() => assertUniqueIcaos(ok)).not.toThrow();
// icao_code empty → the effective key is the ident fallback (toGeoJson), so a duplicate
// ident must trip the guard too.
const dup = toGeoJson(parseCsv([HEADER, row({ ident: 'CCCC', icao_code: '' }), row({ ident: 'CCCC', icao_code: '', latitude_deg: '51.0' })].join('\n')));
expect(() => assertUniqueIcaos(dup)).toThrow(/duplicate ICAO.*CCCC/);
}); });
}); });
@@ -102,7 +111,7 @@ describe('toGeoJson', () => {
}); });
describe('/api/airports handler', () => { describe('/api/airports handler', () => {
it('returns a valid GeoJSON FeatureCollection of EU airports', async () => { it('returns a valid GeoJSON FeatureCollection of world airports', async () => {
const res = await airportsHandler(); const res = await airportsHandler();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json'); expect(res.headers.get('content-type')).toContain('application/json');
+20 -5
View File
@@ -52,11 +52,13 @@ export function parseCsv(text: string): CsvRow[] {
// The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a // The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a
// single "airport" type + category column) — both spellings are accepted. Heliports are // single "airport" type + category column) — both spellings are accepted. Heliports are
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are // excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are
// irrelevant to an airline-ops game. // irrelevant to an airline-ops game. The committed dataset is pre-curated to large_airport
// only (scripts/build-world-airports.mjs); this check stays as a cheap guard against an
// uncurated dump being swapped in.
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']); const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
export function filterEuAirports(rows: CsvRow[]): CsvRow[] { export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
return rows.filter((r) => r.continent === 'EU' && INCLUDED_TYPES.has(r.type)); return rows.filter((r) => INCLUDED_TYPES.has(r.type));
} }
export interface AirportProperties { export interface AirportProperties {
@@ -93,13 +95,26 @@ export function toGeoJson(rows: CsvRow[]): GeoJsonFeatureCollection {
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
} }
// Three consumers key features by ICAO with silent-overwrite semantics (the map's airport
// index, the route/queue GeoJSON builders and the sim's airport lookups) — a duplicate would
// corrupt state without any error, so fail loud at load instead.
export function assertUniqueIcaos(fc: GeoJsonFeatureCollection): void {
const seen = new Set<string>();
for (const f of fc.features) {
if (seen.has(f.properties.icao)) throw new Error(`duplicate ICAO in airport dataset: ${f.properties.icao}`);
seen.add(f.properties.icao);
}
}
let cached: GeoJsonFeatureCollection | null = null; let cached: GeoJsonFeatureCollection | null = null;
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> { export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
if (cached) return cached; if (cached) return cached;
const file = path.join(process.cwd(), 'data', 'airports_eu.csv'); const file = path.join(process.cwd(), 'data', 'airports_world.csv');
const text = await fs.readFile(file, 'utf8'); const text = await fs.readFile(file, 'utf8');
cached = toGeoJson(filterEuAirports(parseCsv(text))); const fc = toGeoJson(filterWorldAirports(parseCsv(text)));
assertUniqueIcaos(fc);
cached = fc;
return cached; return cached;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export const planes = sqliteTable('planes', {
// 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 // 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 burns BROKEN_BURN_MULTIPLIER × more fuel. // landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
health: real('health').notNull().default(100), health: real('health').notNull().default(100),
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status // 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. // 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
+4 -3
View File
@@ -36,9 +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,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0, 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,
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatCargo, 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', () => {
@@ -30,6 +30,14 @@ describe('formatCargo', () => {
}); });
}); });
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');
+5
View File
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`; 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". // Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string { export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1); return special.charAt(0).toUpperCase() + special.slice(1);
+61 -29
View File
@@ -10,7 +10,6 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement'; import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { import {
BROKEN_BURN_MULTIPLIER,
FUEL_DISCOUNT_PER_REP, FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L, FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT, MAX_FUEL_DISCOUNT,
@@ -230,36 +229,28 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
}); });
it('uses the ×1.5 broken burn for the dispatch auto-top-up when health is 0 (v0.39.0, ISS-069)', async () => { it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fc = await loadAirportsGeoJson(); const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const w = weatherFor(HUB_ICAO);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
// The tank holds EXACTLY the healthy requirement — a broken plane's 150% top-up target is higher.
const healthyRequired = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes)
.set({ health: 0, fuel: healthyRequired })
.where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // auto-refuel covers the extra burn — no 409 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 brokenRequired = requiredFuelFor(type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km);
expect(brokenRequired).toBeGreaterThan(healthyRequired); // sanity: the multiplier genuinely raises the requirement
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, brokenRequired * 1.5)), 6); // topped up for the BROKEN burn…
// …while the same tank at full health tops up to the LOWER healthy target — the multiplier is what raised it.
const inst2 = await freshDb('fly-broken-healthy.db');
await inst2.db.update(schema.planes).set({ fuel: healthyRequired }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, healthyRequired * 1.5)), 6);
expect(plane.fuel).toBeGreaterThan(plane2.fuel); // the broken plane genuinely got more fuel
}); });
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) --- // --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
@@ -340,6 +331,34 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
}); });
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
});
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('EHAM');
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
});
}); });
describe('queueLeg (v0.25.0, ISS-043)', () => { describe('queueLeg (v0.25.0, ISS-043)', () => {
@@ -680,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/);
@@ -691,8 +723,8 @@ describe('sellPlane', () => {
}); });
}); });
describe('repairPlane (v0.39.0, ISS-069)', () => { describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => { it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
const inst = await freshDb('repair-ok.db'); const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
@@ -702,7 +734,7 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
expect(cost).toBe(REPAIR_COST); expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing'); expect(plane.status).toBe('repairing');
expect(plane.health).toBe(100); // restored to full… 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) 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 - REPAIR_COST, 6); expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
+26 -9
View File
@@ -9,7 +9,6 @@ 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 { import {
conditionBurnMultiplier,
FUEL_DISCOUNT_PER_REP, FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L, FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT, MAX_FUEL_DISCOUNT,
@@ -106,7 +105,8 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required)) // 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 // 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 // doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. // 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];
@@ -114,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);
@@ -125,9 +128,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
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; wind- // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor. // adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
const w = weatherFor(plane.locationIcao); const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health); 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); 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 // ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
@@ -151,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
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',
@@ -159,6 +167,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
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) 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) { if (cost > 0) {
@@ -338,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');
@@ -355,10 +369,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) --- // --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing' // Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to // Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health // over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
// repair is a free no-op. 404 unknown plane; 409 not grounded / insufficient funds. // and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { export async function repairPlane(inst: GameDb, planeId: string): 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];
@@ -377,8 +393,9 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
} }
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 }); await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
await persist(inst); await persist(inst);
+1 -19
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, 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'; 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.
@@ -43,21 +43,3 @@ describe('repRate', () => {
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT); expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
}); });
}); });
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
describe('conditionBurnMultiplier', () => {
it('is exactly 1 for any positive health', () => {
expect(conditionBurnMultiplier(100)).toBe(1);
expect(conditionBurnMultiplier(42)).toBe(1);
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
});
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
});
it('pins the broken multiplier at ×1.5', () => {
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
});
});
-9
View File
@@ -128,15 +128,6 @@ export const MAX_FUEL_DISCOUNT = 0.2;
// 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). // 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; export const WEAR_PER_KM = 0.0005;
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
export const BROKEN_BURN_MULTIPLIER = 1.5;
// Broken-state burn factor for one plane's health — the third multiplicative term alongside wind at every
// per-plane burn site (flyPlane dispatch top-up + backstop, engine landed fuel + auto-chain, live display).
export function conditionBurnMultiplier(health: number): number {
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
}
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it). // ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
export const REPAIR_COST = 1_500; export const REPAIR_COST = 1_500;
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days). // ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
+98 -1
View File
@@ -11,12 +11,15 @@ import {
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';
@@ -75,7 +78,7 @@ 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), × special multiplier', () => { 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)!;
@@ -131,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);
}); });
}); });
@@ -336,3 +342,94 @@ describe('generatePackage (shared seed/spawn builder)', () => {
}); });
}); });
}); });
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);
}
});
});
+48 -1
View File
@@ -90,9 +90,12 @@ 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 // Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value. // higher reputation, exactly ×1 at the starting value.
@@ -101,6 +104,16 @@ export const JOB_BONUS_PER_REP = 0.01;
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110). // ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
export const MAX_JOB_BONUS = 1.0; 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;
const DEADLINE_MAX_HOURS = 72; const DEADLINE_MAX_HOURS = 72;
@@ -171,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];
@@ -213,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];
} }
+13 -5
View File
@@ -3,7 +3,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state). // Read-only game-state snapshot for client polling (GET /api/state).
@@ -30,7 +30,8 @@ export interface PlaneState {
// 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 // Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired. // 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; health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair. // Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null; repairUntilSimTime: number | null;
@@ -61,6 +62,8 @@ export interface PackageState {
weightKg: number; weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none. // Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null; 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';
@@ -99,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.
@@ -113,12 +122,10 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this // Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed. // recomputes exactly the factor the engine used for the leg; no stored state needed.
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
const w = weatherFor(p.locationIcao); const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted( return { ...pos, fuel: fuelAtWeighted(
p.fuel, p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health), type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
distanceKm, distanceKm,
@@ -163,6 +170,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
cargoCount: p.cargoCount, cargoCount: p.cargoCount,
weightKg: p.weightKg, weightKg: p.weightKg,
special: p.special, 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,
+1
View File
@@ -136,6 +136,7 @@ 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',
+11 -9
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'Flight Ops API', title: 'Flight Ops API',
description: description:
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), 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' },
@@ -223,7 +223,7 @@ export const openApiSpec = {
}, },
409: { 409: {
description: description:
'Plane not grounded, 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', '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 — 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 grounds the plane with the queue kept intact for a later 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' } },
@@ -370,14 +370,14 @@ export const openApiSpec = {
post: { post: {
summary: 'Repair a grounded plane', summary: 'Repair a grounded plane',
description: description:
'Restore a worn-out plane to full health for the flat repair cost (v0.39.0, ISS-069): the plane is grounded in "repairing" status until 3 sim days pass, when the engine\'s tick flips it back to "available". While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) still flies but burns 50% more fuel. A full-health repair is a free no-op (cost 0). No body.', 'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
operationId: 'repairPlane', operationId: 'repairPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
], ],
responses: { responses: {
200: { 200: {
description: 'Repair started (or free no-op) — full state snapshot (health 100, status "repairing" with the gate set, cash reduced by the flat cost)', description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
404: { 404: {
@@ -594,7 +594,7 @@ export const openApiSpec = {
health: { health: {
type: 'number', type: 'number',
description: 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 burns 50% more fuel until repaired.', '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: { repairUntilSimTime: {
type: ['integer', 'null'], type: ['integer', 'null'],
@@ -633,6 +633,7 @@ export const openApiSpec = {
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.' }, 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.
@@ -651,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',
+94 -23
View File
@@ -8,7 +8,7 @@ import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
@@ -313,17 +313,16 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
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);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), post-wear health, // Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
// wind-adjusted requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity. // requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties; const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
const newHealth = Math.max(0, 100 - WEAR_PER_KM * km);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport 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 fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
const nextRequiredL = requiredFuelFor( const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext * conditionBurnMultiplier(newHealth), type.burnPerKm * fNext,
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon), haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
@@ -379,9 +378,55 @@ 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 + broken burn (v0.39.0, ISS-069)', () => { describe('wear on landed legs (v0.39.0, ISS-069)', () => {
const FROM = 'EDDF'; const FROM = 'EDDF';
const TO = 'LEMD'; const TO = 'LEMD';
@@ -433,27 +478,22 @@ describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
expect(plane.health).toBe(0); // still clamped after the second leg expect(plane.health).toBe(0); // still clamped after the second leg
}); });
it('a broken plane burns ×1.5 more fuel on the leg (landed fuel)', async () => { it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
const { inst, from, to, km, type } = await setupFlight('wear-broken.db'); const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter')); // 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); expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
// The ×1.5-adjusted closed form — the same factor livePlane() shows in flight… expect(plane.health).toBe(0); // broken by this landing's wear…
expect(plane.fuel).toBeCloseTo( expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1), expect(plane.destIcao).toBeNull();
6, expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
);
// …and strictly less than the healthy control on the identical leg.
const control = await setupFlight('wear-broken-control.db');
expect(await deliverPackages(control.inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const healthy = (await control.inst.db.select().from(schema.planes))[0];
expect(healthy.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.fuel).toBeLessThan(healthy.fuel); // the broken plane genuinely burned more
}); });
}); });
@@ -487,6 +527,37 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
expect(plane.status).toBe('available'); expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull(); 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)', () => {
+78 -41
View File
@@ -5,13 +5,14 @@ import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { 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';
@@ -129,18 +130,32 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n; return n;
} }
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate // Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows, // round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
// `<=` inclusive at the clock. Returns the count flipped. // 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> { 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')); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
let n = 0; let n = 0;
for (const p of rows) { for (const p of rows) {
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) { 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) await inst.db.update(schema.planes)
.set({ status: 'available', repairUntilSimTime: null }) .set({ status: 'available', health: 100, repairUntilSimTime: null })
.where(eq(schema.planes.id, p.id)); .where(eq(schema.planes.id, p.id));
n++; 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; return n;
@@ -151,8 +166,9 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
// 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; with a short tank, ISS-082 (v0.42.0) tops // broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
// airport; with a short tank, ISS-082 (v0.42.0) tops
// up to 150% of the requirement (capped at tank capacity) when cash allows and // 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 // chains anyway — only an unaffordable top-up grounds the plane there with its
// remaining fuel and the queue kept intact for a later dispatch. // remaining fuel and the queue kept intact for a later dispatch.
@@ -182,12 +198,10 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn — // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used. // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
const w = weatherFor(p.locationIcao); const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted( const landedFuel = fuelAtWeighted(
p.fuel, p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health), type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
distanceKm, distanceKm,
@@ -224,40 +238,51 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone, // (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the // so a short tank no longer strands the chain — when cash allows, top up to 150% of the
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the // 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. // 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;
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0) { 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) { // zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069): // since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what if (nextDest === to.icao) {
// livePlane()/the next landing will compute for that flight. queue.shift();
const wNext = weatherFor(to.icao); } else {
const nextRequiredL = requiredFuelFor( const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth), if (next) {
type.emptyWeightKg, // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
type.maxFuel, const wNext = weatherFor(to.icao);
haversineKm(to.lat, to.lon, next.lat, next.lon), const nextRequiredL = requiredFuelFor(
); type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
if (nextRequiredL <= landedFuel) { type.emptyWeightKg,
queue.shift(); type.maxFuel,
chainedTo = nextDest; haversineKm(to.lat, to.lon, next.lat, next.lon),
} else { );
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. // v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); // intact, mirroring flyPlane's 409 without throwing in the engine.
const liters = Math.max(0, target - landedFuel); if (nextRequiredL > type.maxFuel) {
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. // no chain — the grounded update below keeps the queue for a later dispatch
const fresh = (await inst.db.select().from(schema.account))[0]; } else if (nextRequiredL <= landedFuel) {
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(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; } else {
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); // 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 });
}
} }
} }
} }
@@ -371,9 +396,21 @@ export async function spawnCoveragePackages(
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, reputation: account.reputation }), 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++;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.43.0", "version": "0.48.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Build data/airports_world.csv from the full OurAirports world dump (ISS-079, v0.44.0).
//
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
//
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
// Aborts without writing anything if the download fails after retries — no fabricated data.
import { promises as fs } from 'node:fs';
import path from 'node:path';
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
} else field += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c !== '\r') field += c;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
const [header, ...rest] = rows;
return rest
.filter((r) => r.length === header.length && !(r.length === 1 && r[0] === ''))
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
}
function haversineKm(a, b) {
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.latitude_deg - a.latitude_deg);
const dLon = toRad(b.longitude_deg - a.longitude_deg);
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.latitude_deg)) * Math.cos(toRad(b.latitude_deg)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(x)));
}
// "Biggest" proxy — the CSV carries no passenger/traffic data, so rank by service signals:
// scheduled_service first, then an IATA code (commercially served), then a Wikipedia page,
// then file order. ponytail: good-enough proxy for hub-vs-satellite; swap in pax data if it ever exists.
function biggestOf(members) {
const rank = (r) => [r.scheduled_service === 'yes' ? 1 : 0, r.iata_code ? 1 : 0, r.wikipedia_link ? 1 : 0];
let best = members[0];
for (const m of members.slice(1)) {
const rb = rank(m);
const rk = rank(best);
if (rb[0] > rk[0] || (rb[0] === rk[0] && rb[1] > rk[1]) || (rb[0] === rk[0] && rb[1] === rk[1] && rb[2] > rk[2])) best = m;
}
return best; // ties keep the lowest file order (members are in file order)
}
async function download(url, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (!(text.startsWith('"id"') || text.startsWith('id,')) || text.split('\n').length < 10_000) throw new Error('response does not look like the OurAirports CSV');
return text;
} catch (err) {
console.error(`download attempt ${i}/${attempts} failed: ${err.message}`);
if (i === attempts) throw err;
await new Promise((r) => setTimeout(r, 2000 * i));
}
}
throw new Error('unreachable');
}
const text = await download(SOURCE_URL);
const [headerLine] = text.split('\n');
const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"|"$/g, ''));
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
const allRows = parseCsv(text);
const large = allRows.filter((r) => r.type === 'large_airport');
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
// radius, otherwise it starts a new one. Deterministic — same input, same output.
const clusters = []; // { anchorIdx (into `large`), members }
for (let i = 0; i < large.length; i++) {
let joined = false;
for (const c of clusters) {
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
}
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
}
const kept = [];
const multiClusters = [];
for (const c of clusters) {
const winner = biggestOf(c.members.map((i) => large[i]));
kept.push(winner);
if (c.members.length > 1) {
multiClusters.push({
city: winner.municipality || winner.name,
keptIcao: winner.icao_code || winner.ident,
dropped: c.members.map((i) => large[i]).filter((r) => r !== winner).map((r) => `${r.icao_code || r.ident} (${r.name})`),
});
}
}
// --- Guards (fail loud before writing anything) ---
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
const keys = new Set(kept.map(key));
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
}
if (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); process.exit(1); }
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
console.log(`wrote ${OUT_FILE}: ${kept.length} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
console.log('sample merged cities (kept ← dropped):');
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);