Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1401a2f6eb | ||
|
|
0de0aaffd9 | ||
|
|
3ac7075eb0 | ||
|
|
320bdf3b72 | ||
|
|
dd7159c109 | ||
|
|
78032a2051 | ||
|
|
64da8e67bf | ||
|
|
b2d03846f5 | ||
|
|
68cfad434b | ||
|
|
5d92b5edaa | ||
|
|
4a6a8592e0 | ||
|
|
b8f321dfd5 | ||
|
|
336ed4ee7c | ||
|
|
929452ca33 |
@@ -1,5 +1,49 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.45.0 — 2026-09-21 21:12
|
||||||
|
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
|
||||||
|
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
|
||||||
|
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
|
||||||
|
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
|
||||||
|
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
|
||||||
|
|
||||||
|
## v0.44.0 — 2026-09-21 17:10
|
||||||
|
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
|
||||||
|
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
|
||||||
|
- **Generated jobs are distance-capped.** New `MAX_JOB_LEG_KM = 9000` in `lib/game/seed.ts`: every spawned leg is flyable by at least one catalog plane even in worst-case headwind (b777 full-tank range ~13045 km nominal, ~10871 km with the +20% wind factor — 9000 leaves margin). `generatePackage` rejection-samples unforced origin/dest pairs until they fit (bounded at 25 attempts, then a uniform within-cap fallback); forced endpoints consume no extra draws and stay uncapped (the UNNT→LPPD 409 backstop relies on that), so under-cap RNG streams are byte-identical to before. The engine's coverage spawn caps its random dest through the shared `pickDestWithinCap` helper — one draw in the same stream position as before
|
||||||
|
- **UI/spec/docs.** Map header label → "N world airports" (e2e regex updated), OpenAPI `/api/airports` summary/description reworded to the curated world dataset, PLAN.md dataset mentions updated; initial camera stays EU-tuned (out of scope)
|
||||||
|
- **E2E marker isolation re-verified.** LFRS (Nantes) keeps ≥39 px clearance from its nearest markers (LFRB Brest / LFBD Bordeaux) at the initial zoom [10,52] z4 with the world dataset — no test changes needed beyond the label regex
|
||||||
|
## v0.43.0 — 2026-09-21 15:57
|
||||||
|
- **Wear rate reduced 10× per ISS-085** — `WEAR_PER_KM` in `lib/game/catalog.ts` drops from 0.005 to 0.0005, so plane maintenance status degrades 1/10th as fast (~13 mid-range hops → ~130 hops to break a full-health plane)
|
||||||
|
|
||||||
|
## v0.42.0 — 2026-09-21 15:02
|
||||||
|
- **Auto-refuel on dispatch (ISS-082).** Manual refueling is gone — planes top up automatically at dispatch, so a grounded plane can always start any leg that fits a full tank when cash allows. `flyPlane` (`lib/game/actions.ts`) tops the tank to `min(maxFuel, max(fuel, 1.5 × required))` before departure — the same target/price terms as the removed forFlight mode: liters × FUEL_PRICE_PER_L minus the rep-scaled discount (v0.38.0), deducted from cash; unaffordable → 409 `insufficient funds` with nothing mutated, and a leg that doesn't fit even a full tank still 409s `insufficient fuel` (the backstop). The engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) does the same at landing: when the next queued leg exceeds the landed fuel it tops up to the identical target when cash allows and chains anyway — only an unaffordable top-up grounds the plane with the queue kept intact
|
||||||
|
- **REMOVED** `POST /api/planes/:planeId/refuel` (route + route tests), the `refuelPlane()` action + `RefuelOptions` (`lib/game/actions.ts`), the left-pane "Refuel to full" section and per-leg Refuel button with their dead state/derived values (`components/LeftPane.tsx`), and the OpenAPI `/api/planes/{planeId}/refuel` path + `RefuelRequest` schema — endpoint count 14 → 13, stale refuel mentions reworded in info/state/queue/repair descriptions, and fly's description + 409 now document the auto-refuel
|
||||||
|
- **Test hook.** New POST `/api/test/set-fuel` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, fuel }` forces a plane's fuel — finite number in [0, maxFuel] (400 outside / unknown field), 404 unknown plane; sibling of set-health
|
||||||
|
- **Tests.** Unit: `refuelPlane` suite deleted; new `flyPlane` auto-refuel tests (exact top-up target + rep-discounted cost at rep 30, free no-op on a full tank, cash 0 → 409 insufficient funds with fuel unchanged, UNNT→LPPD backstop still 409s); the broken-burn test reworked to pin that ×1.5 burn raises the top-up target; `engine.test.ts`'s chain test flipped — a short tank now chains via an affordable top-up (post-top-up fuel + exact cost from the real helpers) plus a new cash-0 variant grounding with the queue intact. E2E: every `/refuel` setup/retry site (~25 across 9 specs) replaced by the set-fuel hook, heal helpers drop their refuel-and-retry guard (dispatch auto-refuels), right-pane's per-leg Refuel test and two-planes' "refuel section lists both planes" deleted in favor of one new end-to-end dispatch auto-top-up test (set-fuel drops fuel to 500 L → fly → exact cash delta from snapshot + real helpers, landed fuel = weighted burn from the topped start value), panes.spec.ts now asserts Refuel-UI absence in both panes, api.spec.ts pins thirteen endpoints
|
||||||
|
|
||||||
|
## v0.41.0 — 2026-09-21 13:26
|
||||||
|
- **Queue UI fixes (ISS-077).** Three queue-related map/UI fixes, no server logic changed:
|
||||||
|
- **Would-409 controls are disabled.** The on-board "Fly to …" button (`components/LeftPane.tsx`) is additionally disabled while the plane is flying and its destination is already active or queued (title "Already in flight queue"); the Relocate section disables its GO button for the same reason AND renders the selected plane's active/queued airports as native disabled `<option>`s. Grounded planes are untouched — their clicks go to `/fly`, which has no such conflict
|
||||||
|
- **Hover trace starts at the landing airport.** Hovering an on-board package while its carrier is FLYING now traces from the carrier's LANDING airport (`destIcao`), not its live mid-flight position (`components/Map.tsx`) — the hover previews what happens AFTER this leg lands; the solid active-leg + dotted queue layers already show where it is. Grounded / no-active-leg carriers keep today's behavior exactly (live position = the plane's airport). `hoverTraceGeoJson` returns an empty collection when start == dest, so a package destined for the active leg's destination draws nothing there — the solid layer covers it
|
||||||
|
- **Grounded queues draw dotted lines.** `queuedRoutesGeoJson` (`lib/mapData.ts`) now also draws GROUNDED ('available') planes with a non-empty queue, chaining from their current airport (`locationIcao → queue[0] → …`); flying planes keep the `[destIcao, ...queue]` chain; 'repairing' planes draw nothing. Same source/layer — no new MapLibre layer
|
||||||
|
- **Tests.** Unit: `lib/mapData.test.ts` pins the grounded chain (exact endpoints), the unknown-first-point skip, the repairing skip and the start==dest empty trace; all flying-case pins stay green. E2E: `e2e/hover.spec.ts`'s mid-flight test is reworked — a package destined for a QUEUED stop beyond the active leg, hover traces from the landing airport to that stop with no segment starting at the departure airport (deterministic: paused right after dispatch); new `e2e/queue.spec.ts` test pins the grounded two-leg chain HUB → DEST → QUEUE in the `queue-routes` layer
|
||||||
|
|
||||||
|
## v0.40.0 — 2026-09-21 13:06
|
||||||
|
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
||||||
|
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
||||||
|
|
||||||
|
## v0.39.0 — 2026-09-21 12:02
|
||||||
|
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
|
||||||
|
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
|
||||||
|
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
|
||||||
|
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
|
||||||
|
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
|
||||||
|
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
|
||||||
|
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
|
||||||
|
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
|
||||||
|
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
|
||||||
|
|
||||||
## v0.38.0 — 2026-09-21 10:47
|
## v0.38.0 — 2026-09-21 10:47
|
||||||
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
|
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
|
||||||
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
|
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ No manual flying — planes auto-fly assigned routes; you manage the business fr
|
|||||||
|
|
||||||
## Layout (single screen)
|
## Layout (single screen)
|
||||||
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation
|
- Left pane — Assets: fleet list (plane type, status, fuel, location), wallet, reputation
|
||||||
- Right pane — Jobs & Market: contracts between airports (reward, deadline, cargo/passengers) with accept/dispatch actions; market actions (buy/sell planes, refuel, maintenance)
|
- Right pane — Jobs & Market: contracts between airports (reward, deadline, cargo/passengers) with accept/dispatch actions; market actions (buy/sell planes, maintenance). No manual refueling — dispatch auto-tops-up the tank to 150% of the leg requirement (capped at capacity) when cash allows
|
||||||
- Middle — Map: EU airports as markers, animated plane positions along routes, active contract routes highlighted; click airport → info (runways, local time, weather); click plane → status
|
- Middle — Map: world airports as markers (one per metro area), animated plane positions along routes, active contract routes highlighted; click airport → info (runways, local time, weather); click plane → status
|
||||||
|
|
||||||
## Core loop
|
## Core loop
|
||||||
Accept job → dispatch a plane → watch it fly the route on the map → earn money + reputation → buy better planes / take bigger jobs.
|
Accept job → dispatch a plane → watch it fly the route on the map → earn money + reputation → buy better planes / take bigger jobs.
|
||||||
@@ -28,10 +28,9 @@ Accept job → dispatch a plane → watch it fly the route on the map → earn m
|
|||||||
- Shown on map / airport info; no gameplay effects yet
|
- Shown on map / airport info; no gameplay effects yet
|
||||||
|
|
||||||
## MVP slice
|
## MVP slice
|
||||||
Starter plane + EU airports + contracts + buy/sell + map visualization + weather display.
|
Starter plane + world airports + contracts + buy/sell + map visualization + weather display.
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
- Weather effects: delays, fuel burn changes, closed airports
|
- Weather effects: delays, fuel burn changes, closed airports
|
||||||
- More aircraft types, upgrades (fuel efficiency, cargo capacity) — money sinks
|
- More aircraft types, upgrades (fuel efficiency, cargo capacity) — money sinks
|
||||||
- Multi-region packs (non-EU airports)
|
|
||||||
- Multiplayer (shared world, contract races)
|
- Multiplayer (shared world, contract races)
|
||||||
|
|||||||
@@ -63,17 +63,17 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
it('rejects a leg that does not fit even a full tank with 409 (ISS-082 backstop)', async () => {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
|
// UNNT → LPPD ≈ 7670 km needs ~10k L — beyond the ATR's 6300 L tank, so no auto-refuel can make it flyable.
|
||||||
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
await db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const res = await post('plane-starter', { destIcao: 'LEMD' });
|
const res = await post('plane-starter', { destIcao: 'LPPD' });
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||||
|
|
||||||
// Refill for later tests.
|
// Put the starter back at the hub for later tests.
|
||||||
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
|
await db.update(schema.planes).set({ locationIcao: 'EDDF' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import * as schema from '@/lib/db/schema';
|
|
||||||
import { getGameDb } from '@/lib/db/client';
|
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|
||||||
import { weatherFor } from '@/lib/weather';
|
|
||||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
|
||||||
import { HUB_ICAO } from '@/lib/game/seed';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-refuel-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (planeId: string, body?: unknown) =>
|
|
||||||
POST(
|
|
||||||
new Request(`http://localhost/api/planes/${planeId}/refuel`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
{ params: Promise.resolve({ planeId }) },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('POST /api/planes/:planeId/refuel', () => {
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
|
||||||
const res = await post('ghost');
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
account: { cash: number };
|
|
||||||
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
|
|
||||||
};
|
|
||||||
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
|
||||||
|
|
||||||
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes)
|
|
||||||
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
|
|
||||||
|
|
||||||
// Ground it again for later tests.
|
|
||||||
await db.update(schema.planes)
|
|
||||||
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects the fill-up with 409 when cash is below the cost', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts an explicit {mode:"full"} body exactly like no body', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { mode: 'full' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> };
|
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown mode with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { mode: 'half' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/mode/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects forFlight without a packageId with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { mode: 'forFlight' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/packageId/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown body field with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { liters: 100 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid JSON with 400', async () => {
|
|
||||||
const res = await POST(
|
|
||||||
new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }),
|
|
||||||
{ params: Promise.resolve({ planeId: 'plane-starter' }) },
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.account).set({ cash: 50_000 });
|
|
||||||
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
|
|
||||||
await db.insert(schema.packages).values({
|
|
||||||
id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
|
||||||
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
// Same wind-adjusted requirement the server computes (v0.36.0).
|
|
||||||
const w = weatherFor(HUB_ICAO);
|
|
||||||
const required = requiredFuelFor(
|
|
||||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
|
||||||
type.emptyWeightKg,
|
|
||||||
type.maxFuel,
|
|
||||||
km,
|
|
||||||
);
|
|
||||||
|
|
||||||
const startFuel = Math.round(required / 2); // partial tank so L > 0
|
|
||||||
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
account: { cash: number };
|
|
||||||
planes: Array<{ id: string; fuel: number }>;
|
|
||||||
};
|
|
||||||
// ISS-071: target is 150% of the requirement, capped at tank capacity.
|
|
||||||
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
|
|
||||||
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
|
|
||||||
|
|
||||||
// Clean up the test package.
|
|
||||||
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { performAction, refuelPlane } from '@/lib/game/actions';
|
|
||||||
|
|
||||||
// POST /api/planes/:planeId/refuel (v0.8.0; optional body v0.16.0) — fills a grounded
|
|
||||||
// plane at the fuel price per added liter and deducts the cost from cash. The body is
|
|
||||||
// OPTIONAL: no body, {} or {mode:'full'} fill to maxFuel (the original behavior);
|
|
||||||
// {mode:'forFlight', packageId} adds just enough for that loaded package's destination
|
|
||||||
// leg. 400 bad body (unknown field, unknown mode, forFlight without packageId); 404
|
|
||||||
// unknown plane/package id; 409 if the plane is flying, the package isn't loaded on it
|
|
||||||
// (or is expired), the leg doesn't fit a full tank, or cash is insufficient. On success
|
|
||||||
// returns the full state snapshot (fuel raised by exactly the added liters).
|
|
||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
|
||||||
const { planeId } = await ctx.params;
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const text = await req.text();
|
|
||||||
body = text ? JSON.parse(text) : {};
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
||||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const input = body as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(input)) {
|
|
||||||
if (key !== 'mode' && key !== 'packageId') {
|
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mode = input.mode ?? 'full';
|
|
||||||
if (mode !== 'full' && mode !== 'forFlight') {
|
|
||||||
return Response.json({ error: 'mode must be "full" or "forFlight"' }, { status: 400 });
|
|
||||||
}
|
|
||||||
let packageId: string | undefined;
|
|
||||||
if (input.packageId !== undefined) {
|
|
||||||
if (typeof input.packageId !== 'string' || input.packageId.length === 0) {
|
|
||||||
return Response.json({ error: 'packageId must be a non-empty string' }, { status: 400 });
|
|
||||||
}
|
|
||||||
packageId = input.packageId;
|
|
||||||
}
|
|
||||||
if (mode === 'forFlight' && !packageId) {
|
|
||||||
return Response.json({ error: 'packageId is required when mode is "forFlight"' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return performAction((inst) => refuelPlane(inst, planeId, { mode, packageId }));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { performAction, repairPlane } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
|
||||||
|
// "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
|
||||||
|
// the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
|
||||||
|
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
|
||||||
|
// On success returns the full state snapshot.
|
||||||
|
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
|
const { planeId } = await ctx.params;
|
||||||
|
return performAction((inst) => repairPlane(inst, planeId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.42.0, ISS-082) — lets the e2e suite force a plane's fuel level now that the
|
||||||
|
// manual /refuel endpoint is gone (dispatch auto-refuels instead). Disabled unless FLIGHT_TEST_HOOKS=1
|
||||||
|
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here. Sibling of
|
||||||
|
// /api/test/set-health — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-fuel
|
||||||
|
// body { planeId: string, fuel: number } — both required; planeId must name an existing plane
|
||||||
|
// (404 otherwise) and fuel a finite number in [0, the plane's maxFuel] (400 outside). Sets the
|
||||||
|
// planes row's fuel directly and returns the full state snapshot after ticking.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'planeId' && key !== 'fuel') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const planeId = input.planeId;
|
||||||
|
if (typeof planeId !== 'string' || planeId.length === 0) {
|
||||||
|
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const fuel = input.fuel;
|
||||||
|
if (typeof fuel !== 'number' || !Number.isFinite(fuel)) {
|
||||||
|
return Response.json({ error: 'fuel is required and must be a finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
if (!rows[0]) {
|
||||||
|
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (fuel < 0 || fuel > rows[0].maxFuel) {
|
||||||
|
return Response.json({ error: `fuel must be in [0, maxFuel] — this plane's tank holds ${rows[0].maxFuel} L` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an
|
||||||
|
// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless
|
||||||
|
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other
|
||||||
|
// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-health
|
||||||
|
// body { planeId: string, health?: number, repairUntilMs?: number }
|
||||||
|
// - planeId is required and must name an existing plane (404 otherwise);
|
||||||
|
// - at least one of health / repairUntilMs must be present;
|
||||||
|
// - health must be a finite number in [0, 100];
|
||||||
|
// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package
|
||||||
|
// near-term-gate precedent) so the next advancing tick flips a shortened repair back.
|
||||||
|
// Returns the full state snapshot after ticking.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const planeId = input.planeId;
|
||||||
|
if (typeof planeId !== 'string' || planeId.length === 0) {
|
||||||
|
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
let health: number | undefined;
|
||||||
|
if (input.health !== undefined) {
|
||||||
|
if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) {
|
||||||
|
return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 });
|
||||||
|
}
|
||||||
|
health = input.health;
|
||||||
|
}
|
||||||
|
let repairUntilMs: number | undefined;
|
||||||
|
if (input.repairUntilMs !== undefined) {
|
||||||
|
if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) {
|
||||||
|
return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
repairUntilMs = input.repairUntilMs;
|
||||||
|
}
|
||||||
|
if (health === undefined && repairUntilMs === undefined) {
|
||||||
|
return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const sim = await tickNow(); // clock is current before we set the gate (same as siblings)
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
if (!rows[0]) {
|
||||||
|
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets: Partial<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
|
||||||
|
if (health !== undefined) sets.health = health;
|
||||||
|
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
||||||
|
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,16 @@ body {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ISS-080 (v0.40.0): the version tag links to the repo's CHANGELOG.md — keep it subtle, like the rest of the header. */
|
||||||
|
.app-header a {
|
||||||
|
color: #7dd3fc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Style switcher — kept subtle, like the version tag. */
|
/* Style switcher — kept subtle, like the version tag. */
|
||||||
.style-switch {
|
.style-switch {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -214,6 +224,17 @@ body {
|
|||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
|
||||||
|
.status-broken {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-repairing {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
|
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
|
||||||
.special-badge {
|
.special-badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -237,6 +258,26 @@ body {
|
|||||||
background: #34d399;
|
background: #34d399;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */
|
||||||
|
.health-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(148, 163, 184, 0.25);
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bar > div {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */
|
||||||
|
.repair-btn {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.plane-sub {
|
.plane-sub {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
||||||
refuel/fly/abandon actions push their response snapshot into the shared state */}
|
fly/abandon actions push their response snapshot into the shared state */}
|
||||||
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
|
|||||||
+55
-87
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } 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, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
|||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||||
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
|
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||||
onHover: (target: HoverTarget | null) => void;
|
onHover: (target: HoverTarget | null) => void;
|
||||||
@@ -101,11 +101,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
||||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
|
|
||||||
// "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
|
|
||||||
// a sensible default (first grounded plane with fuel below max).
|
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||||
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||||
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||||
@@ -202,27 +197,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||||
// market row; with state null both fall back to null and the section isn't rendered.
|
// market row; with state null both fall back to null and the section isn't rendered.
|
||||||
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||||||
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
|
// queued instead of started immediately (see relocate-go below).
|
||||||
const relocatable = state ? state.planes : [];
|
const relocatable = state ? state.planes : [];
|
||||||
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||||
const relocateDest =
|
const relocateDest =
|
||||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||||
|
// ISS-077 (v0.41.0): while flying, the Relocate click would POST /queue — block it when the selected
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
// destination is already active or queued (the server rejects that with 409). Grounded planes are unaffected.
|
||||||
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
const relocateBlocked =
|
||||||
// market row; grounded-only, and with state null falls back to null.
|
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||||
const grounded = relocatable.filter((p) => p.status === 'available');
|
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
|
||||||
const refuelPlane =
|
|
||||||
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
|
||||||
refuelable[0] ??
|
|
||||||
null;
|
|
||||||
// v0.38.0 (ISS-072): rep-scaled preview — same formula as the server's refuelPlane 'full' mode.
|
|
||||||
const fuelDiscountRate = state ? repRate(state.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT) : 0;
|
|
||||||
const refuelCost =
|
|
||||||
refuelPlane && state
|
|
||||||
? Math.round((refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL * (1 - fuelDiscountRate) * 100) / 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
||||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
||||||
@@ -368,19 +352,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="plane-status status-available">{p.locationIcao}</span>
|
<>
|
||||||
|
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
|
||||||
|
{p.status === 'repairing' && (
|
||||||
|
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
|
||||||
|
)}
|
||||||
|
{p.health <= 0 && (
|
||||||
|
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="fuel-bar" aria-hidden="true">
|
<div className="fuel-bar" aria-hidden="true">
|
||||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
|
||||||
|
<div className="health-bar" aria-hidden="true">
|
||||||
|
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
|
||||||
|
</div>
|
||||||
<div className="plane-sub">
|
<div className="plane-sub">
|
||||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
|
||||||
|
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
|
||||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
<span className="plane-loc">{planeLocation(p)}</span>
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
|
||||||
|
{p.status === 'available' && p.health < 100 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`repair-${p.id}`}
|
||||||
|
className="pane-btn repair-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
|
||||||
|
>
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{cargo.map((pkg) => {
|
{cargo.map((pkg) => {
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
|
// ISS-077 (v0.41.0): while flying the click would POST /queue — disable it when the
|
||||||
|
// destination is already active or queued (the server rejects that with 409). Grounded
|
||||||
|
// clicks go to /fly, which has no such conflict.
|
||||||
|
const alreadyQueued = p.status === 'flying' && (p.destIcao === pkg.destIcao || p.queue.includes(pkg.destIcao));
|
||||||
return (
|
return (
|
||||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||||
@@ -397,25 +412,19 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`fly-to-${pkg.id}`}
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
className="pane-btn onboard-fly-btn"
|
className="pane-btn onboard-fly-btn"
|
||||||
disabled={actionsBusy}
|
disabled={actionsBusy || alreadyQueued}
|
||||||
|
title={alreadyQueued ? 'Already in flight queue' : undefined}
|
||||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||||||
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||||
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
>
|
>
|
||||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
Fly to {pkg.destName}
|
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||||||
|
Fly to {pkg.destIcao}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* ISS-082 (v0.42.0): the per-leg Refuel button is gone — dispatch auto-refuels. */}
|
||||||
<div className="onboard-actions">
|
<div className="onboard-actions">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`refuel-for-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || p.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
|
||||||
>
|
|
||||||
Refuel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`abandon-${pkg.id}`}
|
data-testid={`abandon-${pkg.id}`}
|
||||||
@@ -428,7 +437,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
|
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||||
|
{pkg.originIcao} → {pkg.destIcao}
|
||||||
|
</span>
|
||||||
{/* v0.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">
|
||||||
@@ -520,7 +532,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||||
>
|
>
|
||||||
{airportOptions?.map((a) => (
|
{airportOptions?.map((a) => (
|
||||||
<option key={a.icao} value={a.icao}>
|
// ISS-077 (v0.41.0): a flying plane's active/queued airports can't be re-selected — the
|
||||||
|
// server would reject them with 409; native disabled options, no text change.
|
||||||
|
<option key={a.icao} value={a.icao} disabled={relocatePlane?.status === 'flying' && (relocatePlane.destIcao === a.icao || relocatePlane.queue.includes(a.icao))}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -529,7 +543,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="relocate-go"
|
data-testid="relocate-go"
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
disabled={actionsBusy || relocatePlane === null || relocateDest === null || relocateBlocked}
|
||||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
||||||
// relocation takes off when the current one lands.
|
// relocation takes off when the current one lands.
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -543,53 +557,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
|
{/* ISS-082 (v0.42.0): the "Refuel to full" section is gone — dispatch auto-refuels instead. */}
|
||||||
about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
|
|
||||||
<section className="pane-section" data-testid="refuel-section">
|
|
||||||
<h3 className="pane-heading">Refuel</h3>
|
|
||||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
|
||||||
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
|
|
||||||
<div className="market-row">
|
|
||||||
{refuelable.length === 0 ? (
|
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
|
||||||
All tanks full.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
data-testid="market-refuel-plane"
|
|
||||||
aria-label="Plane to refuel"
|
|
||||||
value={refuelPlane?.id ?? ''}
|
|
||||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
{refuelable.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span data-testid="market-refuel-cost" className="market-preview">
|
|
||||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
|
||||||
</span>
|
|
||||||
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
|
|
||||||
{fuelDiscountRate > 0 && (
|
|
||||||
<span data-testid="refuel-discount" className="special-badge">−{Math.round(fuelDiscountRate * 100)}%</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="market-refuel"
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || refuelPlane === null}
|
|
||||||
onClick={() => {
|
|
||||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refuel to full
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Time</h3>
|
<h3 className="pane-heading">Time</h3>
|
||||||
|
|||||||
+15
-3
@@ -153,10 +153,20 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
||||||
// not its origin airport — identical to before while grounded at the pickup, different once it
|
// not its origin airport — identical to before while grounded at the pickup, different once it
|
||||||
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
||||||
|
// ISS-077 (v0.41.0): a FLYING carrier's trace starts at its LANDING airport (destIcao), not the
|
||||||
|
// live mid-flight position — the hover previews what happens AFTER this leg lands, and the solid
|
||||||
|
// active-leg + dotted queue layers already show where it is now. Grounded / no active leg keeps
|
||||||
|
// today's behavior exactly (live position = the plane's airport). destIcao missing from the
|
||||||
|
// index → fall back to the live position rather than guessing.
|
||||||
const idx = airportsIndexRef.current ?? new Map();
|
const idx = airportsIndexRef.current ?? new Map();
|
||||||
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
||||||
|
const landing = carrier?.status === 'flying' && carrier.destIcao ? idx.get(carrier.destIcao) : undefined;
|
||||||
void hoverSource.setData(
|
void hoverSource.setData(
|
||||||
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
|
landing
|
||||||
|
? hoverTraceGeoJson(landing, toIcao, idx)
|
||||||
|
: carrier
|
||||||
|
? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx)
|
||||||
|
: hoverRouteGeoJson(fromIcao, toIcao, idx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
||||||
@@ -537,7 +547,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
<h1>Flight Ops</h1>
|
<h1>Flight Ops</h1>
|
||||||
<small>v{pkg.version}</small>
|
<a data-testid="app-version" href="https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md" target="_blank" rel="noreferrer">
|
||||||
|
<small>v{pkg.version}</small>
|
||||||
|
</a>
|
||||||
<select
|
<select
|
||||||
data-testid="style-switch"
|
data-testid="style-switch"
|
||||||
className="style-switch"
|
className="style-switch"
|
||||||
@@ -552,7 +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" />
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50,
|
lat: 50,
|
||||||
lon: 8.5,
|
lon: 8.5,
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
id,ident,type,name,latitude_deg,longitude_deg,elevation_ft,continent,iso_country,iso_region,municipality,scheduled_service,icao_code,iata_code,gps_code,local_code,home_link,wikipedia_link,keywords
|
|
||||||
117,BIAR,large_airport,Akureyri International Airport,65.656573,-18.072018,6,EU,IS,IS-6,Akureyri,yes,BIAR,AEY,BIAR,,https://www.innanlandsflugvellir.is/en/akureyri-airport/,https://en.wikipedia.org/wiki/Akureyri_Airport,
|
|
||||||
123,BIKF,large_airport,Keflavik International Airport,63.985001,-22.6056,171,EU,IS,IS-2,Reykjavík,yes,BIKF,KEF,BIKF,,https://www.isavia.is/en/keflavik-airport,https://en.wikipedia.org/wiki/Keflav%C3%ADk_International_Airport,"Keflavik Naval Air Station, REK"
|
|
||||||
4614,BKPR,large_airport,Priština Adem Jashari International Airport,42.5728,21.035801,1789,EU,XK,XK-01,Prishtina,yes,BKPR,PRN,BKPR,,http://www.airportpristina.com/,https://en.wikipedia.org/wiki/Pristina_International_Airport,"LYPR, Pristina, Slatina Air Base"
|
|
||||||
2155,EBBR,large_airport,Brussels Airport,50.901402,4.48444,175,EU,BE,BE-VBR,Zaventem,yes,EBBR,BRU,EBBR,,http://www.brusselsairport.be/en/,https://en.wikipedia.org/wiki/Brussels_Airport,"Brussels National, Zaventem, EBMB"
|
|
||||||
2163,EBOS,large_airport,Ostend-Bruges International Airport,51.1998,2.874673,13,EU,BE,BE-VWV,Oostende,yes,EBOS,OST,EBOS,,http://www.ost.aero/,https://en.wikipedia.org/wiki/Ostend-Bruges_International_Airport,Ostend-Bruges International Airport
|
|
||||||
301881,EDDB,large_airport,Berlin Brandenburg Airport,52.361738,13.502341,157,EU,DE,DE-BR,Berlin,yes,EDDB,BER,EDDB,,https://ber.berlin-airport.de/,https://en.wikipedia.org/wiki/Berlin_Brandenburg_Airport,Willy Brandt
|
|
||||||
2210,EDDC,large_airport,Dresden Airport,51.134123,13.767831,755,EU,DE,DE-SN,Dresden,yes,EDDC,DRS,EDDC,,http://www.dresden-airport.de/en/index.html,https://en.wikipedia.org/wiki/Dresden_Airport,ETDN
|
|
||||||
2211,EDDE,large_airport,Erfurt-Weimar Airport,50.978281,10.960713,1036,EU,DE,DE-TH,Erfurt,yes,EDDE,ERF,EDDE,,http://www.airport-erfurt.de/,https://en.wikipedia.org/wiki/Erfurt_Airport,Flughafen Erfurt-Weimar
|
|
||||||
2212,EDDF,large_airport,Frankfurt Main Airport,50.026706,8.55835,364,EU,DE,DE-HE,Frankfurt am Main,yes,EDDF,FRA,EDDF,,https://www.frankfurt-airport.de/,https://en.wikipedia.org/wiki/Frankfurt_Airport,"EDAF, Frankfurt am Main, Frankfurt Main, Rhein-Main Air Base, Zeppelinheim"
|
|
||||||
2213,EDDG,large_airport,Münster Osnabrück Airport,52.133816,7.688482,160,EU,DE,DE-NW,Greven,yes,EDDG,FMO,EDDG,,https://www.fmo.de/,https://en.wikipedia.org/wiki/M%C3%BCnster_Osnabr%C3%BCck_International_Airport,
|
|
||||||
2214,EDDH,large_airport,Hamburg Helmut Schmidt Airport,53.630402,9.98823,53,EU,DE,DE-HH,Hamburg,yes,EDDH,HAM,EDDH,,https://www.hamburg-airport.de/en/,https://en.wikipedia.org/wiki/Hamburg_Airport,Hamburg-Fuhlsbüttel Airport
|
|
||||||
2216,EDDK,large_airport,Cologne Bonn Airport,50.865898,7.14274,302,EU,DE,DE-NW,Köln (Cologne),yes,EDDK,CGN,EDDK,,http://www.koeln-bonn-airport.de/en/,https://en.wikipedia.org/wiki/Cologne_Bonn_Airport,Köln
|
|
||||||
2217,EDDL,large_airport,Düsseldorf Airport,51.289501,6.76678,147,EU,DE,DE-NW,Düsseldorf,yes,EDDL,DUS,EDDL,,http://www.dus.com/dus_en/,https://en.wikipedia.org/wiki/D%C3%BCsseldorf_Airport,
|
|
||||||
2218,EDDM,large_airport,Munich Airport,48.353802,11.7861,1487,EU,DE,DE-BY,Munich,yes,EDDM,MUC,EDDM,,http://www.munich-airport.com/,https://en.wikipedia.org/wiki/Munich_Airport,"Franz Josef Strauss Airport, Flughafen München Franz Josef Strauß"
|
|
||||||
2219,EDDN,large_airport,Nuremberg Airport,49.498699,11.078056,1046,EU,DE,DE-BY,Nuremberg,yes,EDDN,NUE,EDDN,,https://www.airport-nuernberg.de/english,https://en.wikipedia.org/wiki/Nuremberg_Airport,Nürnberg
|
|
||||||
2220,EDDP,large_airport,Leipzig/Halle Airport,51.420657,12.232705,465,EU,DE,DE-SN,Schkeuditz,yes,EDDP,LEJ,EDDP,,https://www.mdf-ag.com/,https://en.wikipedia.org/wiki/Leipzig/Halle_Airport,Schkeuditz Airport
|
|
||||||
2222,EDDS,large_airport,Stuttgart Airport,48.689899,9.22196,1276,EU,DE,DE-BW,Stuttgart,yes,EDDS,STR,EDDS,,http://www.flughafen-stuttgart.de/,https://en.wikipedia.org/wiki/Stuttgart_Airport,
|
|
||||||
2224,EDDV,large_airport,Hannover Airport,52.461102,9.68508,183,EU,DE,DE-NI,Hannover,yes,EDDV,HAJ,EDDV,,https://www.hannover-airport.de,https://en.wikipedia.org/wiki/Hanover/Langenhagen_International_Airport,
|
|
||||||
2225,EDDW,large_airport,Bremen Airport,53.046786,8.78932,14,EU,DE,DE-HB,Bremen,yes,EDDW,BRE,EDDW,,https://www.bremen-airport.com,https://en.wikipedia.org/wiki/Bremen_Airport,
|
|
||||||
2227,EDFH,large_airport,Frankfurt-Hahn Airport,49.946353,7.261734,1649,EU,DE,DE-RP,Frankfurt am Main (Lautzenhausen),yes,EDFH,HHN,EDFH,,https://www.hahn-airport.de/en/home,https://en.wikipedia.org/wiki/Frankfurt-Hahn_Airport,
|
|
||||||
2237,EDJA,large_airport,Memmingen Allgau Airport,47.988092,10.238248,2077,EU,DE,DE-BY,Memmingen,yes,EDJA,FMM,EDJA,,,https://en.wikipedia.org/wiki/Memmingen_Airport,ETSM
|
|
||||||
2244,EDLP,large_airport,Paderborn Lippstadt Airport,51.612527,8.617459,699,EU,DE,DE-NW,Büren,yes,EDLP,PAD,EDLP,,,https://en.wikipedia.org/wiki/Paderborn_Lippstadt_Airport,
|
|
||||||
2247,EDLW,large_airport,Dortmund Airport,51.518299,7.61224,425,EU,DE,DE-NW,Dortmund,yes,EDLW,DTM,EDLW,,https://www.dortmund-airport.com/,https://en.wikipedia.org/wiki/Dortmund_Airport,
|
|
||||||
2256,EDNY,large_airport,Bodensee Airport Friedrichshafen,47.671299,9.51149,1367,EU,DE,DE-BW,Friedrichshafen,yes,EDNY,FDH,EDNY,,https://www.bodensee-airport.eu,https://en.wikipedia.org/wiki/Friedrichshafen_Airport,Bodensee Airport Friedrichshafen
|
|
||||||
2270,EDSB,large_airport,Karlsruhe Baden-Baden Airport,48.7794,8.0805,408,EU,DE,DE-BW,Rheinmünster,yes,EDSB,FKB,EDSB,,http://www.baden-airpark.de/startseite.html,https://en.wikipedia.org/wiki/Baden_Airpark,Baden Airpark
|
|
||||||
2285,EDVK,large_airport,Kassel Airport,51.418395,9.391638,820,EU,DE,DE-HE,Calden,yes,EDVK,KSF,EDVK,,http://www.flughafenkassel.de/,https://en.wikipedia.org/wiki/Kassel_Airport,Kassel-Calden
|
|
||||||
2301,EETN,large_airport,Lennart Meri Tallinn Airport,59.413246,24.83264,131,EU,EE,EE-37,Tallinn,yes,EETN,TLL,EETN,,https://airport.ee/,https://en.wikipedia.org/wiki/Tallinn_Airport,Tallinna Lennujaam
|
|
||||||
2307,EFHK,large_airport,Helsinki Vantaa Airport,60.318363,24.963341,179,EU,FI,FI-18,Helsinki (Vantaa),yes,EFHK,HEL,EFHK,,https://www.finavia.fi/en/airports/helsinki-airport,https://en.wikipedia.org/wiki/Helsinki_Airport,
|
|
||||||
2314,EFIV,large_airport,Ivalo Airport,68.6073,27.4053,481,EU,FI,FI-10,Ivalo,yes,EFIV,IVL,EFIV,,http://www.finavia.fi/en/ivalo/,https://en.wikipedia.org/wiki/Ivalo_Airport,
|
|
||||||
2325,EFKT,large_airport,Kittilä International Airport,67.700996,24.8468,644,EU,FI,FI-10,Kittilä,yes,EFKT,KTT,EFKT,,http://www.finavia.fi/en/kittila/,https://en.wikipedia.org/wiki/Kittil%C3%A4_Airport,
|
|
||||||
2326,EFKU,large_airport,Kuopio Airport,63.007099,27.7978,323,EU,FI,FI-15,Kuopio / Siilinjärvi,yes,EFKU,KUO,EFKU,,https://www.finavia.fi/en/kuopio/,https://en.wikipedia.org/wiki/Kuopio_Airport,
|
|
||||||
2329,EFLP,large_airport,Lappeenranta Airport,61.044601,28.144743,349,EU,FI,FI-02,Lappeenranta,yes,EFLP,LPP,EFLP,,https://lppairport.fi/en,https://en.wikipedia.org/wiki/Lappeenranta_Airport,
|
|
||||||
2334,EFOU,large_airport,Oulu Airport,64.930099,25.354601,47,EU,FI,FI-14,Oulu / Oulunsalo,yes,EFOU,OUL,EFOU,,https://www.finavia.fi/en/oulu/,https://en.wikipedia.org/wiki/Oulu_Airport,
|
|
||||||
2341,EFRO,large_airport,Rovaniemi Airport,66.563327,25.829751,642,EU,FI,FI-10,Rovaniemi,yes,EFRO,RVN,EFRO,,http://www.finavia.fi/en/rovaniemi/,https://en.wikipedia.org/wiki/Rovaniemi_Airport,
|
|
||||||
2347,EFTP,large_airport,Tampere-Pirkkala Airport,61.414101,23.604401,390,EU,FI,FI-11,Tampere / Pirkkala,yes,EFTP,TMP,EFTP,,https://www.finavia.fi/en/tampere-pirkkala/,https://en.wikipedia.org/wiki/Tampere-Pirkkala_Airport,
|
|
||||||
2349,EFTU,large_airport,Turku Airport,60.514099,22.2628,161,EU,FI,FI-19,Turku,yes,EFTU,TKU,EFTU,,https://www.finavia.fi/en/turku/,https://en.wikipedia.org/wiki/Turku_Airport,
|
|
||||||
2351,EFVA,large_airport,Vaasa Airport,63.05023,21.762543,19,EU,FI,FI-12,Vaasa,yes,EFVA,VAA,EFVA,,http://www.finavia.fi/en/vaasa/,https://en.wikipedia.org/wiki/Vaasa_Airport,
|
|
||||||
2385,EGAA,large_airport,Belfast International Airport,54.6575012207,-6.2158298492399995,268,EU,GB,GB-NIR,Belfast,yes,EGAA,BFS,EGAA,,http://www.belfastairport.com/,https://en.wikipedia.org/wiki/Belfast_International_Airport,"Aldergrove, RAF Aldergrove"
|
|
||||||
2389,EGBB,large_airport,Birmingham Airport,52.453899,-1.74803,327,EU,GB,GB-ENG,"Birmingham, West Midlands",yes,EGBB,BHX,EGBB,,https://www.birminghamairport.co.uk/,https://en.wikipedia.org/wiki/Birmingham_Airport,Birmingham International Airport
|
|
||||||
2398,EGCC,large_airport,Manchester Airport,53.349375,-2.279521,257,EU,GB,GB-ENG,"Manchester, Greater Manchester",yes,EGCC,MAN,EGCC,,http://www.manchesterairport.co.uk/,https://en.wikipedia.org/wiki/Manchester_Airport,"Ringway Airport, RAF Ringway"
|
|
||||||
2415,EGFF,large_airport,Cardiff International Airport,51.396702,-3.34333,220,EU,GB,GB-WLS,Cardiff,yes,EGFF,CWL,EGFF,,http://www.cwlfly.com/,https://en.wikipedia.org/wiki/Cardiff_Airport,"Caerdydd, Cardiff International Airport, Maes Awyr Caerdydd, Rhoose, Y Rhws"
|
|
||||||
2417,EGGD,large_airport,Bristol Airport,51.382326,-2.716453,622,EU,GB,GB-ENG,Bristol,yes,EGGD,BRS,EGGD,,http://www.bristolairport.co.uk/,https://en.wikipedia.org/wiki/Bristol_Airport,Lulsgate Bottom
|
|
||||||
2418,EGGP,large_airport,Liverpool John Lennon Airport,53.334863,-2.849637,80,EU,GB,GB-ENG,Liverpool,yes,EGGP,LPL,EGGP,,http://www.liverpoolairport.com/,https://en.wikipedia.org/wiki/Liverpool_John_Lennon_Airport,
|
|
||||||
2434,EGLL,large_airport,London Heathrow Airport,51.470748,-0.459909,83,EU,GB,GB-ENG,London,yes,EGLL,LHR,EGLL,,http://www.heathrow.com/,https://en.wikipedia.org/wiki/Heathrow_Airport,"LON, Londres"
|
|
||||||
2444,EGNM,large_airport,Leeds Bradford Airport,53.865898,-1.66057,681,EU,GB,GB-ENG,"Leeds, West Yorkshire",yes,EGNM,LBA,EGNM,,http://www.lbia.co.uk/,https://en.wikipedia.org/wiki/Leeds_Bradford_International_Airport,
|
|
||||||
2447,EGNS,large_airport,Isle of Man Airport,54.083081,-4.623871,52,EU,IM,IM-U-A,Castletown,yes,EGNS,IOM,EGNS,,http://www.iom-airport.com/,https://en.wikipedia.org/wiki/Isle_of_Man_Airport,"Ronaldsway, Douglas"
|
|
||||||
2448,EGNT,large_airport,Newcastle International Airport,55.037958,-1.689577,266,EU,GB,GB-ENG,"Newcastle upon Tyne, Tyne and Wear",yes,EGNT,NCL,EGNT,,http://www.newcastleairport.com/,https://en.wikipedia.org/wiki/Newcastle_International_Airport,
|
|
||||||
2450,EGNX,large_airport,East Midlands Airport,52.8311,-1.32806,306,EU,GB,GB-ENG,"Nottingham, Leicestershire",yes,EGNX,EMA,EGNX,,http://www.eastmidlandsairport.com/,https://en.wikipedia.org/wiki/East_Midlands_Airport,RAF Castle Donington
|
|
||||||
2459,EGPD,large_airport,Aberdeen International Airport,57.2019,-2.19778,215,EU,GB,GB-SCT,Aberdeen,yes,EGPD,ABZ,EGPD,,http://www.aberdeenairport.com/,https://en.wikipedia.org/wiki/Aberdeen_Airport,Dyce
|
|
||||||
2461,EGPF,large_airport,Glasgow Airport,55.871899,-4.43306,26,EU,GB,GB-SCT,Glasgow,yes,EGPF,GLA,EGPF,,http://www.glasgowairport.com/,https://en.wikipedia.org/wiki/Glasgow_International_Airport,"Port-adhair Eadar-nàiseanta Ghlaschu, RAF Abbotsinch, RAF Ayr"
|
|
||||||
2462,EGPH,large_airport,Edinburgh Airport,55.950145,-3.372288,135,EU,GB,GB-SCT,"Ingliston, Edinburgh",yes,EGPH,EDI,EGPH,,http://www.edinburghairport.com/,https://en.wikipedia.org/wiki/Edinburgh_Airport,
|
|
||||||
2513,EHAM,large_airport,Amsterdam Airport Schiphol,52.308601,4.76389,-11,EU,NL,NL-NH,Amsterdam,yes,EHAM,AMS,EHAM,,https://www.schiphol.nl/,https://en.wikipedia.org/wiki/Amsterdam_Airport_Schiphol,
|
|
||||||
2515,EHBK,large_airport,Maastricht Aachen Airport,50.911087,5.769401,375,EU,NL,NL-LI,Maastricht,yes,EHBK,MST,EHBK,,,https://en.wikipedia.org/wiki/Maastricht_Aachen_Airport,
|
|
||||||
2518,EHEH,large_airport,Eindhoven Airport,51.4500999451,5.37452983856,74,EU,NL,NL-NB,Eindhoven,yes,EHEH,EIN,EHEH,,http://www.eindhovenairport.nl/,https://en.wikipedia.org/wiki/Eindhoven_Airport,Welschap Airport
|
|
||||||
2519,EHGG,large_airport,Groningen Airport Eelde,53.119107,6.577652,17,EU,NL,NL-DR,Groningen,yes,EHGG,GRQ,EHGG,,,https://en.wikipedia.org/wiki/Groningen_Airport_Eelde,
|
|
||||||
2524,EHRD,large_airport,Rotterdam The Hague Airport,51.956902,4.43722,-15,EU,NL,NL-ZH,Rotterdam,yes,EHRD,RTM,EHRD,,https://www.rotterdamthehagueairport.nl/,https://en.wikipedia.org/wiki/Rotterdam_The_Hague_Airport,Vliegveld Zestienhoven
|
|
||||||
2530,EICK,large_airport,Cork International Airport,51.841301,-8.49111,502,EU,IE,IE-CO,Cork,yes,EICK,ORK,EICK,,http://www.corkairport.com/,https://en.wikipedia.org/wiki/Cork_Airport,Aerfort Chorcaí
|
|
||||||
2533,EIDW,large_airport,Dublin Airport,53.428713,-6.262121,242,EU,IE,IE-D,Dublin,yes,EIDW,DUB,EIDW,,http://www.dublinairport.com/,https://en.wikipedia.org/wiki/Dublin_Airport,Aerfort Bhaile Átha Cliath
|
|
||||||
2534,EIKN,large_airport,Ireland West Airport Knock,53.910439,-8.817002,665,EU,IE,IE-MO,Charlestown,yes,EIKN,NOC,EIKN,,http://www.irelandwestairport.com/,https://en.wikipedia.org/wiki/Ireland_West_Airport_Knock,"Connaught, Horan International Airport"
|
|
||||||
2537,EINN,large_airport,Shannon Airport,52.702,-8.92482,46,EU,IE,IE-CE,Shannon,yes,EINN,SNN,EINN,,http://www.shannonairport.com/,https://en.wikipedia.org/wiki/Shannon_Airport,
|
|
||||||
2540,EKAH,large_airport,Aarhus Airport,56.303331,10.618286,82,EU,DK,DK-82,Aarhus,yes,EKAH,AAR,EKAH,,http://www.aar.dk/,https://en.wikipedia.org/wiki/Aarhus_Airport,Tirstrup
|
|
||||||
2541,EKBI,large_airport,Billund Airport,55.740335,9.157019,247,EU,DK,DK-83,Billund,yes,EKBI,BLL,EKBI,,http://www.billund-airport.dk/?sc_lang=en,https://en.wikipedia.org/wiki/Billund_Airport,
|
|
||||||
2542,EKCH,large_airport,Copenhagen Kastrup Airport,55.617900848389,12.656000137329,17,EU,DK,DK-84,Copenhagen,yes,EKCH,CPH,EKCH,,http://www.cph.dk/en/,https://en.wikipedia.org/wiki/Copenhagen_Airport,"København, Malmö"
|
|
||||||
2549,EKOD,large_airport,Odense Hans Christian Andersen Airport,55.475307,10.327206,56,EU,DK,DK-83,Odense,yes,EKOD,ODE,EKOD,,,https://en.wikipedia.org/wiki/Hans_Christian_Andersen_Airport,Beldringe
|
|
||||||
2559,EKVG,large_airport,Vágar Airport,62.063256,-7.275782,280,EU,FO,FO-U-A,Vágar,yes,EKVG,FAE,EKVG,,https://www.fae.fo/,https://en.wikipedia.org/wiki/V%C3%A1gar_Airport,"Faroes, RAF Vágar"
|
|
||||||
2562,EKYT,large_airport,Aalborg Airport,57.094763,9.84993,10,EU,DK,DK-81,Aalborg,yes,EKYT,AAL,EKYT,,http://www.aal.dk,https://en.wikipedia.org/wiki/Aalborg_Airport,
|
|
||||||
2563,ELLX,large_airport,Luxembourg-Findel International Airport,49.626845,6.212134,1234,EU,LU,LU-L,Luxembourg,yes,ELLX,LUX,ELLX,,https://www.lux-airport.lu/en/,https://en.wikipedia.org/wiki/Luxembourg-Findel_International_Airport,
|
|
||||||
2564,ENAL,large_airport,Ålesund Airport,62.560443,6.110845,69,EU,NO,NO-15,Ålesund,yes,ENAL,AES,ENAL,,https://www.avinor.no/airport/alesund/,https://en.wikipedia.org/wiki/%C3%85lesund_Airport%2C_Vigra,"Vigra, Ålesund Vigra Airport"
|
|
||||||
2569,ENBO,large_airport,Bodø Airport,67.269203,14.3653,42,EU,NO,NO-18,Bodø,yes,ENBO,BOO,ENBO,,http://www.avinor.no/en/airport/bodo/,https://en.wikipedia.org/wiki/Bod%C3%B8_Airport,"Bodø Air Force Base, Bodø Main Air Station"
|
|
||||||
2570,ENBR,large_airport,"Bergen Airport, Flesland",60.2934,5.21814,170,EU,NO,NO-46,Bergen,yes,ENBR,BGO,ENBR,,http://www.avinor.no/en/airport/bergen,https://en.wikipedia.org/wiki/Bergen_Airport%2C_Flesland,
|
|
||||||
2572,ENCN,large_airport,Kristiansand Airport,58.204201,8.08537,57,EU,NO,NO-42,Kristiansand(Kjevik),yes,ENCN,KRS,ENCN,,http://www.avinor.no/en/airport/kristiansand,https://en.wikipedia.org/wiki/Kristiansand_Airport%2C_Kjevik,Kjevik Airport
|
|
||||||
2575,ENEV,large_airport,Harstad/Narvik Airport,68.491302,16.678101,84,EU,NO,NO-18,Evenes,yes,ENEV,EVE,ENEV,,https://avinor.no/en/airport/harstadnarvik-airport/,https://en.wikipedia.org/wiki/Harstad/Narvik_Airport%2C_Evenes,"Harstad/Narvik lufthavn, Evenes, Evenes Airport"
|
|
||||||
2578,ENGM,large_airport,Oslo-Gardermoen International Airport,60.193901,11.1004,681,EU,NO,NO-32,Oslo (Gardermoen),yes,ENGM,OSL,ENGM,,https://avinor.no/en/airport/oslo-airport,"https://en.wikipedia.org/wiki/Oslo_Airport,_Gardermoen",
|
|
||||||
2599,ENTC,large_airport,Tromsø Airport,69.683296,18.9189,31,EU,NO,NO-55,Tromsø,yes,ENTC,TOS,ENTC,,http://www.avinor.no/en/airport/tromso,https://en.wikipedia.org/wiki/Troms%C3%B8_Airport,"Langnes, Tromsøya, Tromso"
|
|
||||||
2600,ENTO,large_airport,"Sandefjord Airport, Torp",59.186699,10.2586,286,EU,NO,NO-39,Sandefjord(Torp),yes,ENTO,TRF,ENTO,,https://torp.no/,https://en.wikipedia.org/wiki/Sandefjord_Airport%2C_Torp,Torp
|
|
||||||
2601,ENVA,large_airport,"Trondheim Airport, Værnes",63.457802,10.924,56,EU,NO,NO-50,Trondheim,yes,ENVA,TRD,ENVA,,https://avinor.no/en/airport/trondheim-airport/,https://en.wikipedia.org/wiki/Trondheim_Airport%2C_V%C3%A6rnes,
|
|
||||||
2602,ENZV,large_airport,"Stavanger Airport, Sola",58.876701,5.63778,29,EU,NO,NO-11,Stavanger,yes,ENZV,SVG,ENZV,,http://www.avinor.no/en/airport/stavanger/,"https://en.wikipedia.org/wiki/Stavanger_Airport,_Sola","Sola Air Station, Stavanger lufthavn, Sola"
|
|
||||||
2608,EPGD,large_airport,Gdańsk Lech Wałęsa Airport,54.377602,18.4662,489,EU,PL,PL-PM,Gdańsk,yes,EPGD,GDN,EPGD,,http://www.airport.gdansk.pl/,https://en.wikipedia.org/wiki/Gda%C5%84sk_Lech_Wa%C5%82%C4%99sa_Airport,Gdańsk-Rębiechowo
|
|
||||||
2609,EPKK,large_airport,Kraków John Paul II International Airport,50.077702,19.7848,791,EU,PL,PL-MA,Balice,yes,EPKK,KRK,EPKK,,http://www.krakowairport.pl/en/,https://en.wikipedia.org/wiki/Kraków_John_Paul_II_International_Airport,Krakov
|
|
||||||
2613,EPKT,large_airport,Katowice Wojciech Korfanty International Airport,50.476015,19.080705,995,EU,PL,PL-SL,Katowice,yes,EPKT,KTW,EPKT,,http://katowice.airport.com,https://en.wikipedia.org/wiki/Katowice_International_Airport,"Pyrzowice, Dąbrowa Górnicza, Katowice, Silesia, GOP, Silesia Metropolis, Gliwice, Piekary Śląskie, Bytom, Zabrze, Sosnowiec"
|
|
||||||
309484,EPLB,large_airport,Lublin Airport,51.240157,22.713461,633,EU,PL,PL-LU,Lublin,yes,EPLB,LUZ,EPLB,,,https://en.wikipedia.org/wiki/Lublin_Airport,
|
|
||||||
2616,EPLL,large_airport,Łódź Władysław Reymont Airport,51.721901,19.3981,604,EU,PL,PL-LD,Łódź,yes,EPLL,LCJ,EPLL,,https://www.lodz-airport.pl/en,https://en.wikipedia.org/wiki/%C5%81%C3%B3d%C5%BA_W%C5%82adys%C5%82aw_Reymont_Airport,Lublinek
|
|
||||||
2625,EPPO,large_airport,Poznań-Ławica Airport,52.421598,16.823359,308,EU,PL,PL-WP,Poznań,yes,EPPO,POZ,EPPO,,https://poznanairport.pl,https://en.wikipedia.org/wiki/Pozna%C5%84-%C5%81awica_Airport,Poznań–Ławica Henryk Wieniawski Airport
|
|
||||||
2629,EPRZ,large_airport,Rzeszów-Jasionka Airport,50.109791,22.024155,693,EU,PL,PL-PK,Jasionka,yes,EPRZ,RZE,EPRZ,,https://www.rzeszowairport.pl,https://en.wikipedia.org/wiki/Rzesz%C3%B3w-Jasionka_Airport,
|
|
||||||
2630,EPSC,large_airport,Solidarity Szczecin–Goleniów Airport,53.584702,14.9022,154,EU,PL,PL-ZP,Szczecin(Glewice),yes,EPSC,SZZ,EPSC,,http://www.airport.com.pl/en/index.php,https://en.wikipedia.org/wiki/Szczecin-Goleni%C3%B3w_%22Solidarno%C5%9B%C4%87%22_Airport,"Solidarność, Glewice, Port Lotniczy Szczecin–Goleniów im. NSZZ Solidarność"
|
|
||||||
2637,EPWA,large_airport,Warsaw Chopin Airport,52.165699,20.9671,362,EU,PL,PL-MZ,Warsaw,yes,EPWA,WAW,EPWA,,http://www.lotnisko-chopina.pl/?lang=en,https://en.wikipedia.org/wiki/Warsaw_Frederic_Chopin_Airport,"Okęcie, Warszawa"
|
|
||||||
2638,EPWR,large_airport,Copernicus Wrocław Airport,51.103719,16.882096,404,EU,PL,PL-DS,Wrocław,yes,EPWR,WRO,EPWR,,http://www.airport.wroclaw.pl/en/index.php,https://en.wikipedia.org/wiki/Copernicus_Airport_Wroc%C5%82aw,"Strachowice, Breslau, Wroclaw"
|
|
||||||
2648,ESGG,large_airport,Göteborg Landvetter Airport,57.6628,12.2798,506,EU,SE,SE-Q,Göteborg,yes,ESGG,GOT,ESGG,,https://www.swedavia.com/landvetter/,https://en.wikipedia.org/wiki/G%C3%B6teborg_Landvetter_Airport,"Göteborg-Landvetter, Gothenburg"
|
|
||||||
2660,ESKN,large_airport,Stockholm Skavsta Airport,58.789656,16.911479,140,EU,SE,SE-D,Nyköping,yes,ESKN,NYO,ESKN,,,https://en.wikipedia.org/wiki/Stockholm-Skavsta_Airport,
|
|
||||||
316498,ESKS,large_airport,Scandinavian Mountains Airport,61.16508,12.83352,1608,EU,SE,SE-W,Malung-Sälen,yes,ESKS,SCR,ESKS,,https://scandinavianmountains.se/en/home/,https://en.wikipedia.org/wiki/Scandinavian_Mountains_Airport,"Sälens Flygplats, Trysil, Sälen Trysil Airport, Sälen/Scandinavian Mountains Airport"
|
|
||||||
2669,ESMS,large_airport,Malmö Sturup Airport,55.535564,13.376327,236,EU,SE,SE-M,Malmö,yes,ESMS,MMX,ESMS,,http://www.swedavia.com/malmo/,https://en.wikipedia.org/wiki/Malm%C3%B6_Airport,
|
|
||||||
2685,ESNQ,large_airport,Kiruna Airport,67.821999,20.3368,1508,EU,SE,SE-BD,Kiruna,yes,ESNQ,KRN,ESNQ,,https://www.swedavia.com/kiruna/,https://en.wikipedia.org/wiki/Kiruna_Airport,
|
|
||||||
2689,ESNU,large_airport,Umeå Airport,63.791801,20.282801,24,EU,SE,SE-AC,Umeå,yes,ESNU,UME,ESNU,,https://www.swedavia.com/umea/,https://en.wikipedia.org/wiki/Ume%C3%A5_Airport,
|
|
||||||
2696,ESOW,large_airport,Stockholm Västerås Airport,59.589401,16.6336,21,EU,SE,SE-U,Stockholm / Västerås,yes,ESOW,VST,ESOW,,,https://en.wikipedia.org/wiki/Stockholm-V%C3%A4ster%C3%A5s_Airport,
|
|
||||||
2697,ESPA,large_airport,Luleå Airport,65.5438,22.122,65,EU,SE,SE-BD,Luleå,yes,ESPA,LLA,ESPA,,https://www.swedavia.com/lulea/,https://en.wikipedia.org/wiki/Lule%C3%A5_Airport,Kallax Air Base
|
|
||||||
2701,ESSA,large_airport,Stockholm-Arlanda Airport,59.64849,17.928829,137,EU,SE,SE-AB,Stockholm,yes,ESSA,ARN,ESSA,,http://www.swedavia.se/arlanda/,https://en.wikipedia.org/wiki/Stockholm-Arlanda_Airport,
|
|
||||||
2706,ESSL,large_airport,Linköping City Airport,58.404909,15.684499,172,EU,SE,SE-E,Linköping,yes,ESSL,LPI,ESSL,,http://www.linkopingsflygplats.se/,https://en.wikipedia.org/wiki/Link%C3%B6ping_SAAB_Airport,
|
|
||||||
2710,ESSV,large_airport,Visby Airport,57.6628,18.346201,164,EU,SE,SE-I,Visby,yes,ESSV,VBY,ESSV,,https://www.swedavia.com/visby/,https://en.wikipedia.org/wiki/Visby_Airport,
|
|
||||||
2757,EVLA,large_airport,Liepāja International Airport,56.517502,21.096901,16,EU,LV,LV-112,Liepāja,no,EVLA,LPX,EVLA,,http://www.liepaja-airport.lv/,https://en.wikipedia.org/wiki/Liep%C4%81ja_International_Airport,
|
|
||||||
2758,EVRA,large_airport,Riga International Airport,56.920752,23.970711,36,EU,LV,LV-062,Riga,yes,EVRA,RIX,EVRA,,http://www.riga-airport.com/,https://en.wikipedia.org/wiki/Riga_International_Airport,
|
|
||||||
2760,EYKA,large_airport,Kaunas International Airport,54.963965,24.08582,256,EU,LT,LT-KU,Kaunas,yes,EYKA,KUN,EYKA,,http://www.kaunasair.lt/index.php?lang=2&m=2&p=210,https://en.wikipedia.org/wiki/Kaunas_Airport,Kauno tarptautinis Oro Uostas
|
|
||||||
2762,EYPA,large_airport,Palanga International Airport,55.973202,21.093901,33,EU,LT,LT-KL,Palanga,yes,EYPA,PLQ,EYPA,,http://www.palanga-airport.lt/,https://en.wikipedia.org/wiki/Palanga_International_Airport,
|
|
||||||
2766,EYVI,large_airport,Vilnius International Airport,54.634102,25.285801,648,EU,LT,LT-VL,Vilnius,yes,EYVI,VNO,EYVI,,http://www.vilnius-airport.lt/en/,https://en.wikipedia.org/wiki/Vilnius_International_Airport,
|
|
||||||
3972,LATI,large_airport,Tirana International Airport Mother Teresa,41.4147,19.7206,126,EU,AL,AL-02,Rinas,yes,LATI,TIA,LATI,,http://www.tirana-airport.com/,https://en.wikipedia.org/wiki/Tirana_International_Airport_N%C3%ABn%C3%AB_Tereza,"Rinas International Airport, Aeroporti Nënë Tereza"
|
|
||||||
3974,LBBG,large_airport,Burgas Airport,42.569917,27.515173,135,EU,BG,BG-02,Burgas,yes,LBBG,BOJ,LBBG,,,https://en.wikipedia.org/wiki/Burgas_Airport,Bourgas
|
|
||||||
3976,LBPD,large_airport,Plovdiv International Airport,42.067799,24.8508,597,EU,BG,BG-16,Plovdiv,yes,LBPD,PDV,LBPD,,http://www.plovdivairport.com/,https://en.wikipedia.org/wiki/Plovdiv_International_Airport,
|
|
||||||
3977,LBSF,large_airport,Sofia Airport,42.696357,23.417671,1742,EU,BG,BG-22,Sofia,yes,LBSF,SOF,LBSF,,http://sofia-airport.bg,https://en.wikipedia.org/wiki/Sofia_Airport,"Sofia International Airport, Vrazhdebna Air Base"
|
|
||||||
3979,LBWN,large_airport,Varna Airport,43.232101,27.8251,230,EU,BG,BG-03,Varna,yes,LBWN,VAR,LBWN,,,https://en.wikipedia.org/wiki/Varna_Airport,Aksakovo
|
|
||||||
3984,LDDU,large_airport,Dubrovnik Ruđer Bošković Airport,42.562247,18.265543,527,EU,HR,HR-19,Dubrovnik,yes,LDDU,DBV,LDDU,,https://www.airport-dubrovnik.hr/en,https://en.wikipedia.org/wiki/Dubrovnik_Airport,"Močići Airport, Zračna luka Ruđer Bošković Dubrovnik"
|
|
||||||
3987,LDPL,large_airport,Pula Airport,44.893501,13.9222,274,EU,HR,HR-18,Pula,yes,LDPL,PUY,LDPL,,https://airport-pula.hr/en/,https://en.wikipedia.org/wiki/Pula_Airport,
|
|
||||||
3989,LDRI,large_airport,Rijeka Airport,45.216376,14.57085,278,EU,HR,HR-08,Rijeka(Omišalj),yes,LDRI,RJK,LDRI,,https://www.rijeka-airport.hr/,https://en.wikipedia.org/wiki/Rijeka_Airport,"Reka, Rika, Fiume, Omišalj"
|
|
||||||
3991,LDSP,large_airport,Split Saint Jerome Airport,43.538898,16.298,79,EU,HR,HR-17,Split,yes,LDSP,SPU,LDSP,,https://split-airport.hr,https://en.wikipedia.org/wiki/Split_Airport,
|
|
||||||
3993,LDZA,large_airport,Zagreb Franjo Tuđman International Airport,45.742901,16.0688,353,EU,HR,HR-01,Velika Gorica,yes,LDZA,ZAG,LDZA,,https://www.zagreb-airport.hr/en,https://en.wikipedia.org/wiki/Zagreb_Airport,Pleso
|
|
||||||
3994,LDZD,large_airport,Zadar Airport,44.096986,15.353565,289,EU,HR,HR-13,Zadar,yes,LDZD,ZAD,LDZD,,https://www.zadar-airport.hr/en,https://en.wikipedia.org/wiki/Zadar_Airport,
|
|
||||||
3997,LEAL,large_airport,Alicante-Elche Miguel Hernández Airport,38.2822,-0.558156,142,EU,ES,ES-VC,Alicante,yes,LEAL,ALC,LEAL,,https://www.aena.es/en/alicante-elche.html,https://en.wikipedia.org/wiki/Alicante–Elche_Miguel_Hernández_Airport,
|
|
||||||
3999,LEAS,large_airport,Asturias Airport,43.563599,-6.03462,416,EU,ES,ES-AS,Ranón,yes,LEAS,OVD,LEAS,,https://www.aena.es/en/asturias.html,https://en.wikipedia.org/wiki/Asturias_Airport,
|
|
||||||
4001,LEBB,large_airport,Bilbao Airport,43.301102,-2.91061,138,EU,ES,ES-PV,Bilbao,yes,LEBB,BIO,LEBB,,https://www.aena.es/en/bilbao.html,https://en.wikipedia.org/wiki/Bilbao_Airport,
|
|
||||||
4004,LEBL,large_airport,Josep Tarradellas Barcelona-El Prat Airport,41.2971,2.07846,12,EU,ES,ES-CT,Barcelona,yes,LEBL,BCN,LEBL,,https://www.aena.es/en/josep-tarradellas-barcelona-el-prat.html,https://en.wikipedia.org/wiki/Barcelona%E2%80%93El_Prat_Airport,"El Prat Airport,Barcelona International,Josep Tarradellas Barcelona-El Prat Airport"
|
|
||||||
4010,LEGE,large_airport,Girona-Costa Brava Airport,41.904639,2.761774,468,EU,ES,ES-CT,Girona,yes,LEGE,GRO,LEGE,,https://www.aena.es/en/girona-costa-brava.html,https://en.wikipedia.org/wiki/Girona-Costa_Brava_Airport,"Girona, Costa Brava, Girona-Costa Brava"
|
|
||||||
4013,LEIB,large_airport,Ibiza Airport,38.872898,1.37312,24,EU,ES,ES-IB,Ibiza (Eivissa),yes,LEIB,IBZ,LEIB,,https://www.aena.es/en/ibiza.html,https://en.wikipedia.org/wiki/Ibiza_Airport,
|
|
||||||
4019,LEMD,large_airport,Adolfo Suárez Madrid–Barajas Airport,40.493407,-3.572249,1998,EU,ES,ES-MD,Madrid,yes,LEMD,MAD,LEMD,,https://www.aena.es/en/adolfo-suarez-madrid-barajas.html,https://en.wikipedia.org/wiki/Adolfo_Su%C3%A1rez_Madrid%E2%80%93Barajas_Airport,"Leganés, Madrid Barajas International Airport"
|
|
||||||
4020,LEMG,large_airport,Málaga-Costa del Sol Airport,36.6749,-4.49911,53,EU,ES,ES-AN,Málaga,yes,LEMG,AGP,LEMG,,https://www.aena.es/en/malaga-costa-del-sol.html,https://en.wikipedia.org/wiki/M%C3%A1laga_Airport,"Costa del Sol, Malaga"
|
|
||||||
4021,LEMH,large_airport,Menorca Airport,39.862598,4.21865,302,EU,ES,ES-IB,Mahón (Maó),yes,LEMH,MAH,LEMH,,https://www.aena.es/en/menorca.html,https://en.wikipedia.org/wiki/Menorca_Airport,Menorca
|
|
||||||
308134,LEMI,large_airport,Region of Murcia International Airport,37.802852,-1.124884,644,EU,ES,ES-MC,Corvera,yes,LEMI,RMU,LEMI,,https://www.aena.es/en/internacional-region-de-murcia.html,https://en.wikipedia.org/wiki/Murcia-Corvera_Airport,
|
|
||||||
4035,LEPA,large_airport,Palma de Mallorca Airport,39.551701,2.73881,27,EU,ES,ES-IB,Palma de Mallorca,yes,LEPA,PMI,LEPA,,https://www.aena.es/en/palma-de-mallorca.html,https://en.wikipedia.org/wiki/Son_Sant_Joan_Airport,"Son Sant Joan Airport, LESJ"
|
|
||||||
4031,LERS,large_airport,Reus Airport,41.147509,1.168354,233,EU,ES,ES-CT,Reus,yes,LERS,REU,LERS,,https://www.aena.es/en/reus.html,https://en.wikipedia.org/wiki/Reus_Airport,
|
|
||||||
4038,LEST,large_airport,Santiago-Rosalía de Castro Airport,42.896301,-8.41514,1213,EU,ES,ES-GA,Santiago de Compostela,yes,LEST,SCQ,LEST,,https://www.aena.es/en/santiago-rosalia-de-castro.html,https://en.wikipedia.org/wiki/Santiago_de_Compostela_Airport,
|
|
||||||
4042,LEVC,large_airport,Valencia Airport,39.489162,-0.480961,240,EU,ES,ES-VC,Valencia,yes,LEVC,VLC,LEVC,,https://www.aena.es/en/valencia.html,https://en.wikipedia.org/wiki/Valencia_Airport,
|
|
||||||
4048,LEZG,large_airport,Zaragoza Airport,41.666199,-1.04155,863,EU,ES,ES-AR,Zaragoza,yes,LEZG,ZAZ,LEZG,,https://www.aena.es/en/zaragoza.html,https://en.wikipedia.org/wiki/Zaragoza_Airport,Zaragoza Air Base
|
|
||||||
4049,LEZL,large_airport,Seville Airport,37.417999,-5.89311,112,EU,ES,ES-AN,Seville,yes,LEZL,SVQ,LEZL,,https://www.aena.es/en/seville.html,https://en.wikipedia.org/wiki/Seville_Airport,"Sevilla, Sevilla Airport"
|
|
||||||
4060,LFBD,large_airport,Bordeaux–Mérignac Airport,44.82865,-0.715356,162,EU,FR,FR-NAQ,Bordeaux,yes,LFBD,BOD,LFBD,,https://www.bordeaux.aeroport.fr/,https://en.wikipedia.org/wiki/Bordeaux%E2%80%93M%C3%A9rignac_Airport,
|
|
||||||
4070,LFBO,large_airport,Toulouse-Blagnac Airport,43.629101,1.36382,499,EU,FR,FR-OCC,Toulouse/Blagnac,yes,LFBO,TLS,LFBO,,http://www.toulouse.aeroport.fr/,https://en.wikipedia.org/wiki/Toulouse_Blagnac_International_Airport,Airbus
|
|
||||||
4123,LFKB,large_airport,Bastia-Poretta International airport,42.5527,9.48373,26,EU,FR,FR-COR,Bastia,yes,LFKB,BIA,LFKB,,https://bastia-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Bastia_-_Poretta_Airport,
|
|
||||||
4125,LFKF,large_airport,Figari Sud-Corse Airport,41.50185,9.097092,85,EU,FR,FR-COR,Figari,yes,LFKF,FSC,LFKF,,https://figari-aeroport.cci.corsica/,https://en.wikipedia.org/wiki/Figari_Sud-Corse_Airport,
|
|
||||||
4132,LFLC,large_airport,Clermont-Ferrand Auvergne airport,45.786701,3.16917,1090,EU,FR,FR-ARA,Clermont-Ferrand,yes,LFLC,CFE,LFLC,,https://www.clermont-aeroport.com/en,https://en.wikipedia.org/wiki/Clermont-Ferrand_Auvergne_Airport,
|
|
||||||
4137,LFLL,large_airport,Lyon Saint-Exupéry Airport,45.725996,5.090139,821,EU,FR,FR-ARA,"Colombier-Saugnieu, Rhône",yes,LFLL,LYS,LFLL,,https://www.lyonaeroports.com/,https://en.wikipedia.org/wiki/Lyon%E2%80%93Saint-Exup%C3%A9ry_Airport,"Lyon, Saint-Exupéry, Satolas"
|
|
||||||
4155,LFML,large_airport,Marseille Provence Airport,43.438088,5.2125,74,EU,FR,FR-PAC,"Marignane, Bouches-du-Rhône",yes,LFML,MRS,LFML,,https://www.marseille.aeroport.fr/,https://en.wikipedia.org/wiki/Marseille_Provence_Airport,
|
|
||||||
4156,LFMN,large_airport,Nice-Côte d'Azur Airport,43.658401,7.21587,12,EU,FR,FR-PAC,"Nice, Alpes-Maritimes",yes,LFMN,NCE,LFMN,,https://www.nice.aeroport.fr/en,https://en.wikipedia.org/wiki/C%C3%B4te_d'Azur_International_Airport,
|
|
||||||
4161,LFMT,large_airport,Montpellier-Méditerranée Airport,43.576199,3.96301,17,EU,FR,FR-OCC,Montpellier/Méditerranée,yes,LFMT,MPL,LFMT,,https://www.montpellier.aeroport.fr/,https://en.wikipedia.org/wiki/Montpellier_-_M%C3%A9diterran%C3%A9e_Airport,
|
|
||||||
4169,LFOB,large_airport,Beauvais-Tillé airport,49.454399,2.11278,359,EU,FR,FR-HDF,Beauvais,yes,LFOB,BVA,LFOB,,https://www.aeroportparisbeauvais.com/,https://en.wikipedia.org/wiki/Paris_Beauvais_Till%C3%A9_Airport,Beauvais-Tillé
|
|
||||||
4185,LFPG,large_airport,Charles de Gaulle International Airport,49.00896,2.554117,392,EU,FR,FR-IDF,"Paris (Roissy-en-France, Val-d'Oise)",yes,LFPG,CDG,LFPG,,http://www.aeroportsdeparis.fr/,https://en.wikipedia.org/wiki/Charles_de_Gaulle_Airport,"PAR, Aéroport Roissy-Charles de Gaulle, Roissy Airport"
|
|
||||||
4202,LFQQ,large_airport,Lille Airport,50.566564,3.102429,157,EU,FR,FR-HDF,Lesquin,yes,LFQQ,LIL,LFQQ,,https://www.lille.aeroport.fr/home/,https://en.wikipedia.org/wiki/Lille_Airport,Aéroport de Lille. Lille-Lesquin Airport
|
|
||||||
4206,LFRB,large_airport,Brest Bretagne airport,48.447899,-4.41854,325,EU,FR,FR-BRE,Brest,yes,LFRB,BES,LFRB,,https://www.brest.aeroport.bzh,https://en.wikipedia.org/wiki/Brest_Bretagne_Airport,
|
|
||||||
4221,LFRS,large_airport,Nantes Atlantique Airport,47.153198,-1.61073,90,EU,FR,FR-PDL,Nantes,yes,LFRS,NTE,LFRS,,https://www.nantes.aeroport.fr/en,https://en.wikipedia.org/wiki/Nantes_Atlantique_Airport,
|
|
||||||
4226,LFSB,large_airport,EuroAirport Basel–Mulhouse–Freiburg,47.60068,7.521117,885,EU,FR,FR-GES,Bâle / Mulhouse,yes,LFSB,BSL,LFSB,,http://www.euroairport.com/,https://en.wikipedia.org/wiki/EuroAirport_Basel-Mulhouse-Freiburg,"MLH, EAP, LSZM, Bâle, Basle"
|
|
||||||
4238,LFST,large_airport,Strasbourg Airport,48.5383,7.62823,505,EU,FR,FR-GES,Strasbourg,yes,LFST,SXB,LFST,,http://www.strasbourg.aeroport.fr/E/index.php,https://en.wikipedia.org/wiki/Strasbourg_Airport,BA 124
|
|
||||||
4251,LGAV,large_airport,Athens Eleftherios Venizelos International Airport,37.936401,23.9445,308,EU,GR,GR-I,Spata-Artemida,yes,LGAV,ATH,LGAV,,https://www.aia.gr,https://en.wikipedia.org/wiki/Athens_International_Airport,
|
|
||||||
4258,LGIR,large_airport,Heraklion International Nikos Kazantzakis Airport,35.339699,25.1803,115,EU,GR,GR-M,Heraklion,yes,LGIR,HER,LGIR,,https://www.heraklion-airport.gr/en/,"https://en.wikipedia.org/wiki/Heraklion_International_Airport_""Nikos_Kazantzakis""",Crete Island
|
|
||||||
4266,LGKR,large_airport,Corfu Ioannis Kapodistrias International Airport,39.60145,19.912179,6,EU,GR,GR-F,Kerkyra (Corfu),yes,LGKR,CFU,LGKR,,https://www.cfu-airport.gr/en,https://en.wikipedia.org/wiki/Corfu_International_Airport,Corfu
|
|
||||||
4268,LGKV,large_airport,Kavala Alexander the Great International Airport,40.9133,24.6192,18,EU,GR,GR-A,Kavala,yes,LGKV,KVA,LGKV,,https://www.kva-airport.gr/en,https://en.wikipedia.org/wiki/Kavala_International_Airport,Megas Alexandros
|
|
||||||
4281,LGSA,large_airport,Chania International Airport,35.531207,24.150673,490,EU,GR,GR-M,Souda,yes,LGSA,CHQ,LGSA,,,https://en.wikipedia.org/wiki/Chania_International_Airport,
|
|
||||||
4286,LGSR,large_airport,Santorini International Airport,36.400045,25.478638,127,EU,GR,GR-L,Santorini Island,yes,LGSR,JTR,LGSR,,https://www.jtr-airport.gr/,https://en.wikipedia.org/wiki/Santorini_(Thira)_National_Airport,
|
|
||||||
4293,LGTS,large_airport,Thessaloniki Macedonia International Airport,40.51928,22.970009,22,EU,GR,GR-B,Thessaloniki,yes,LGTS,SKG,LGTS,,https://www.skg-airport.gr/en,https://en.wikipedia.org/wiki/Thessaloniki_Airport,Makedonia. Macedonia. Salonica
|
|
||||||
4296,LHBP,large_airport,Budapest Liszt Ferenc International Airport,47.43018,19.262393,495,EU,HU,HU-BU,Budapest,yes,LHBP,BUD,LHBP,,http://www.bud.hu/english,https://en.wikipedia.org/wiki/Budapest_Ferenc_Liszt_International_Airport,"Ferihegyi nemzetközi repülőtér, Budapest Liszt Ferenc international Airport"
|
|
||||||
4297,LHDC,large_airport,Debrecen International Airport,47.489469,21.616278,359,EU,HU,HU-HB,Debrecen,yes,LHDC,DEB,LHDC,,http://www.airportdebrecen.hu/,https://en.wikipedia.org/wiki/Debrecen_International_Airport,Debreceni nemzetközi repülőtér
|
|
||||||
4302,LHPP,large_airport,Pécs-Pogány International Airport,45.988891,18.242044,651,EU,HU,HU-BA,Pécs,yes,LHPP,PEV,LHPP,,http://www.airportpecs.hu/,https://en.wikipedia.org/wiki/P%C3%A9cs-Pog%C3%A1ny_International_Airport,"Pecs South Airport, QPJ"
|
|
||||||
4310,LIBD,large_airport,Bari Karol Wojtyła International Airport,41.138901,16.760599,193,EU,IT,IT-75,Bari,yes,LIBD,BRI,LIBD,BA02,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=25,https://en.wikipedia.org/wiki/Bari_Karol_Wojty%C5%82a_Airport,"Bari ""Karol Wojtyla"" International Airport, Palese Macchie Airport, Palese Airport"
|
|
||||||
4314,LIBP,large_airport,Abruzzo Airport,42.431079,14.182981,48,EU,IT,IT-65,Pescara,yes,LIBP,PSR,LIBP,PE01,http://www.abruzzo-airport.it/eng/index.php,https://en.wikipedia.org/wiki/Abruzzo_Airport,"Pescara, Abruzzo Pasquale Liberi International"
|
|
||||||
4315,LIBR,large_airport,Brindisi Airport,40.6576,17.947001,47,EU,IT,IT-75,Brindisi,yes,LIBR,BDS,LIBR,BR03,http://www.seap-puglia.it/default.asp?idlingua=2&idcontenuto=29,https://en.wikipedia.org/wiki/Brindisi_Airport,"Brindisi Papola Casale Airport, Brindisi Salento Airport, Brindisi Casale Antonio Papolo Airport, Salento Airport"
|
|
||||||
4317,LICA,large_airport,Lamezia Terme Sant'Eufemia International Airport,38.906214,16.246007,39,EU,IT,IT-78,Lamezia Terme (CZ),yes,LICA,SUF,LICA,CZ02,,https://en.wikipedia.org/wiki/Lamezia_Terme_International_Airport,
|
|
||||||
4318,LICC,large_airport,Catania-Fontanarossa Airport,37.466801,15.0664,39,EU,IT,IT-82,Catania,yes,LICC,CTA,LICC,CT03,http://www.aeroporto.catania.it/,https://en.wikipedia.org/wiki/Catania-Fontanarossa_Airport,"Lanza Di Trabie, Catania-Fontanarossa Vincenzo Bellini"
|
|
||||||
4321,LICJ,large_airport,Falcone–Borsellino Airport,38.175999,13.091,65,EU,IT,IT-82,Palermo,yes,LICJ,PMO,LICJ,PA03,http://www.gesap.it/index.php,https://en.wikipedia.org/wiki/Falcone%E2%80%93Borsellino_Airport,"Palermo Airport, Punta Raisi Airport"
|
|
||||||
4332,LIEE,large_airport,Cagliari Elmas Airport,39.251499,9.05428,13,EU,IT,IT-88,Cagliari,yes,LIEE,CAG,LIEE,CA07,http://www.cagliariairport.it/en/,https://en.wikipedia.org/wiki/Cagliari_Elmas_Airport,"Cagliari Airport,Elmas"
|
|
||||||
4333,LIEO,large_airport,Olbia Costa Smeralda Airport,40.898953,9.518457,37,EU,IT,IT-88,Olbia (SS),yes,LIEO,OLB,LIEO,SS03,https://www.geasar.it/en,https://en.wikipedia.org/wiki/Olbia_Costa_Smeralda_Airport,
|
|
||||||
4340,LIMC,large_airport,Milan Malpensa International Airport,45.6306,8.72811,768,EU,IT,IT-25,Ferno (VA),yes,LIMC,MXP,LIMC,MI12,http://www.sea-aeroportimilano.it/en/malpensa/index.phtml,https://en.wikipedia.org/wiki/Malpensa_Airport,"MIL, Silvio Berlusconi"
|
|
||||||
4342,LIMF,large_airport,Turin Airport,45.200802,7.64963,989,EU,IT,IT-21,Caselle Torinese (TO),yes,LIMF,TRN,LIMF,TO11,http://www.aeroportoditorino.it/,https://en.wikipedia.org/wiki/Turin_International_Airport,"Torino-Caselle Airport, Sandro Pertini Airport, Turin-Caselle Airport"
|
|
||||||
4344,LIMJ,large_airport,Genoa Cristoforo Colombo Airport,44.412039,8.840732,13,EU,IT,IT-42,Genova (GE),yes,LIMJ,GOA,LIMJ,GE01,http://www.aeroportodigenova.com/en/home/index.php,https://en.wikipedia.org/wiki/Genoa_Cristoforo_Colombo_Airport,"Genova Sestri, Sestri Cristoforo Colombo Airport, Cristoforo Colombo Airport"
|
|
||||||
4354,LIPE,large_airport,Bologna Guglielmo Marconi Airport,44.5354,11.2887,123,EU,IT,IT-45,Bologna,yes,LIPE,BLQ,LIPE,BO08,http://www.bologna-airport.it/?LN=UK,https://en.wikipedia.org/wiki/Bologna_Airport,"Guglielmo Marconi Airport, Borgo Panigale Airport"
|
|
||||||
4361,LIPQ,large_airport,Trieste Airport,45.827862,13.466672,39,EU,IT,IT-36,Ronchi dei Legionari/Trieste,yes,LIPQ,TRS,LIPQ,GO02,http://www.triesteairport.it,https://en.wikipedia.org/wiki/Trieste_%E2%80%93_Friuli_Venezia_Giulia_Airport,"Ronchi Dei Legionari Airport, Trieste-Ronchi dei Legionari Airport"
|
|
||||||
4362,LIPR,large_airport,Federico Fellini International Airport,44.020024,12.612198,40,EU,IT,IT-45,Rimini (RN),yes,LIPR,RMI,LIPR,RN01,http://www.riminiairport.com/english/,https://en.wikipedia.org/wiki/Federico_Fellini_International_Airport,"Rimini Miramare Airport, Rimini San Marino International Airport"
|
|
||||||
4366,LIPX,large_airport,Verona Villafranca Valerio Catullo Airport,45.394955,10.887303,239,EU,IT,IT-34,Caselle (VR),yes,LIPX,VRN,LIPX,VR10,http://www.aeroportoverona.it/,https://en.wikipedia.org/wiki/Verona_Airport,"Valerio Catullo, Villafranca International Airport, Valerio Catullo Airport, Villafranca Airport"
|
|
||||||
4368,LIPZ,large_airport,Venice Marco Polo Airport,45.505299,12.3519,7,EU,IT,IT-34,Venezia (VE),yes,LIPZ,VCE,LIPZ,VE05,http://www.veniceairport.com/core/index.jsp?_requestid=37300&language=en,https://en.wikipedia.org/wiki/Venice_Marco_Polo_Airport,"Venezia-Tessera, Venice-Tessera"
|
|
||||||
4372,LIRF,large_airport,Rome–Fiumicino Leonardo da Vinci International Airport,41.804532,12.251998,13,EU,IT,IT-62,Rome,yes,LIRF,FCO,LIRF,RM11,http://www.adr.it/portal/portal/adr/Fiumicino/Leonardo_da_vinci/Header_Window?action=2,https://en.wikipedia.org/wiki/Rome_Fiumicino_Airport,"ROM, Rome Fiumicino Airport, Fiumicino Airport"
|
|
||||||
4378,LIRN,large_airport,Naples International Airport,40.886002,14.2908,294,EU,IT,IT-72,Napoli,yes,LIRN,NAP,LIRN,NA01,http://www.portal.gesac.it/,https://en.wikipedia.org/wiki/Naples_International_Airport,"Capodichino Airport, Aeroporto Internazionale di Napoli-Capodichino Ugo Niutta, Ariopuorto 'nternazziunale 'e Napule-Capodichino Ugo Niutta"
|
|
||||||
4379,LIRP,large_airport,Pisa International Airport,43.683899,10.3927,6,EU,IT,IT-52,Pisa (PI),yes,LIRP,PSA,LIRP,PI05,http://www.pisa-airport.com/,https://en.wikipedia.org/wiki/Pisa_International_Airport,"Galileo Galilei Airport, San Giusto Airport"
|
|
||||||
4380,LIRQ,large_airport,"Florence Airport, Peretola",43.808558,11.202822,142,EU,IT,IT-52,Firenze (FI),yes,LIRQ,FLR,LIRQ,FI04,http://www.aeroporto.firenze.it/,https://en.wikipedia.org/wiki/Florence_Airport,"Firenze, Amerigo Vespucci Airport, Aeroporto di Firenze-Peretola"
|
|
||||||
4384,LIRZ,large_airport,Perugia San Francesco d'Assisi – Umbria International Airport,43.095901,12.5132,697,EU,IT,IT-55,Perugia (PG),yes,LIRZ,PEG,LIRZ,PG10,http://www.airport.umbria.it/,https://en.wikipedia.org/wiki/Perugia_San_Francesco_d%27Assisi_%E2%80%93_Umbria_International_Airport,"Perugia San Francesco d'Assisi, Perugia Sant'Egidio Airport, Umbria International Airport"
|
|
||||||
4386,LJLJ,large_airport,Ljubljana Jože Pučnik Airport,46.223701,14.4576,1273,EU,SI,SI-012,Zgornji Brnik,yes,LJLJ,LJU,LJLJ,,http://www.lju-airport.si/,https://en.wikipedia.org/wiki/Ljubljana_Jo%C5%BEe_Pu%C4%8Dnik_Airport,"Ljubljana, Kranj"
|
|
||||||
4393,LKCS,large_airport,České Budějovice South Bohemian Airport,48.948171,14.42832,1417,EU,CZ,CZ-JC,České Budějovice,yes,LKCS,JCL,LKCS,,https://www.airport-cb.cz/,https://en.wikipedia.org/wiki/%C4%8Cesk%C3%A9_Bud%C4%9Bjovice_Airport,Jihočeské letiště České Budějovice
|
|
||||||
4399,LKKV,large_airport,Karlovy Vary Airport,50.202999,12.915,1989,EU,CZ,CZ-KA,Karlovy Vary,yes,LKKV,KLV,LKKV,,http://www.airport-k-vary.cz/indexEN.php,https://en.wikipedia.org/wiki/Karlovy_Vary_Airport,Karlovy Vary International
|
|
||||||
4402,LKMT,large_airport,Leoš Janáček Airport Ostrava,49.696301,18.111099,844,EU,CZ,CZ-MO,Mošnov,yes,LKMT,OSR,LKMT,,http://www.airport-ostrava.cz/en/,https://en.wikipedia.org/wiki/Ostrava_Leos_Janacek_Airport,Ostrava-Mošnov International Airport
|
|
||||||
4405,LKPD,large_airport,Pardubice Airport,50.015049,15.73981,741,EU,CZ,CZ-PA,Pardubice,yes,LKPD,PED,LKPD,,http://www.airport-pardubice.cz/?lang=en,https://en.wikipedia.org/wiki/Pardubice_Airport,Letiště Pardubice
|
|
||||||
4408,LKPR,large_airport,Václav Havel Airport Prague,50.100874,14.259911,1247,EU,CZ,CZ-PR,Prague,yes,LKPR,PRG,LKPR,,http://www.prg.aero,https://en.wikipedia.org/wiki/Václav_Havel_Airport_Prague,"Letiště Praha-Ruzyně, Ruzyně International Airport, Letiště Václava Havla Praha"
|
|
||||||
4427,LMML,large_airport,Malta International Airport,35.845932,14.491546,300,EU,MT,MT-25,Valletta,yes,LMML,MLA,LMML,,https://www.maltairport.com/,https://en.wikipedia.org/wiki/Malta_International_Airport,"Luqa Airport, Valletta, Gudja, RAF Luqa"
|
|
||||||
4430,LOWG,large_airport,Graz Airport,46.9911,15.4396,1115,EU,AT,AT-6,Feldkirchen bei Graz,yes,LOWG,GRZ,LOWG,,http://www.flughafen-graz.at/,https://en.wikipedia.org/wiki/Graz_Airport,"Thalerhof Airport, Fliegerhorst Nittner, Nittner Air Base, Christophorus 12"
|
|
||||||
4431,LOWI,large_airport,Innsbruck Airport,47.260201,11.344,1907,EU,AT,AT-7,Innsbruck,yes,LOWI,INN,LOWI,,http://www.innsbruck-airport.com/,https://en.wikipedia.org/wiki/Innsbruck_Airport,Kranebitten Airport
|
|
||||||
4435,LOWK,large_airport,Klagenfurt Airport,46.642502,14.3377,1472,EU,AT,AT-2,Klagenfurt am Wörthersee,yes,LOWK,KLU,LOWK,,http://www.klagenfurt-airport.com/,https://en.wikipedia.org/wiki/Klagenfurt_Airport,"Kärnten Airport, Christophorus 11"
|
|
||||||
4432,LOWL,large_airport,Linz-Hörsching Airport,48.235362,14.188128,980,EU,AT,AT-4,Linz,yes,LOWL,LNZ,LOWL,,http://www.flughafen-linz.at/,https://en.wikipedia.org/wiki/Linz_Airport,"Blue Danube Airport, Vogler Air Base, Fliegerhorst Vogler, Christophorus 10"
|
|
||||||
4433,LOWS,large_airport,Salzburg Airport,47.793301,13.0043,1411,EU,AT,AT-5,Salzburg,yes,LOWS,SZG,LOWS,,http://www.salzburg-airport.com/,https://en.wikipedia.org/wiki/Salzburg_Airport,Christophorus 6
|
|
||||||
4434,LOWW,large_airport,Vienna International Airport,48.110298,16.5697,600,EU,AT,AT-9,Vienna,yes,LOWW,VIE,LOWW,,http://www.viennaairport.com/en/,https://en.wikipedia.org/wiki/Vienna_International_Airport,
|
|
||||||
4448,LPFR,large_airport,Faro - Gago Coutinho International Airport,37.015909,-7.970939,24,EU,PT,PT-08,Faro,yes,LPFR,FAO,LPFR,,http://faroairport.pt,https://en.wikipedia.org/wiki/Faro_Airport_(Portugal),
|
|
||||||
4452,LPMA,large_airport,Cristiano Ronaldo International Airport,32.697812,-16.774613,192,EU,PT,PT-30,Funchal,yes,LPMA,FNC,LPMA,,http://www.aeroportomadeira.pt,https://en.wikipedia.org/wiki/Madeira_Airport,"Madeira, Funchal"
|
|
||||||
4456,LPPD,large_airport,João Paulo II Airport,37.7411994934,-25.6979007721,259,EU,PT,PT-20,Ponta Delgada,yes,LPPD,PDL,LPPD,,http://www.ana.pt/en-US/Aeroportos/acores/PontaDelgada/Pages/Homepage-PontaDelgada.aspx,https://en.wikipedia.org/wiki/Jo%C3%A3o_Paulo_II_Airport,
|
|
||||||
4459,LPPR,large_airport,Francisco de Sá Carneiro Airport,41.2481002808,-8.68138980865,228,EU,PT,PT-13,Porto,yes,LPPR,OPO,LPPR,,http://www.ana.pt/en-US/Aeroportos/porto/Porto/Pages/Homepage-Porto.aspx,https://en.wikipedia.org/wiki/Francisco_S%C3%A1_Carneiro_Airport,
|
|
||||||
4461,LPPT,large_airport,Lisbon Humberto Delgado Airport,38.7813,-9.13592,374,EU,PT,PT-11,Lisbon,yes,LPPT,LIS,LPPT,,https://lisbonairport.pt,https://en.wikipedia.org/wiki/Lisbon_Airport,"Lisboa, Portela"
|
|
||||||
4467,LQBK,large_airport,Banja Luka International Airport,44.941399,17.297501,400,EU,BA,BA-SRP,Mahovljani,yes,LQBK,BNX,LQBK,,http://www.banjaluka-airport.com/,https://en.wikipedia.org/wiki/Banja_Luka_International_Airport,Mahovljani Airport
|
|
||||||
4468,LQMO,large_airport,Mostar International Airport,43.282489,17.846101,156,EU,BA,BA-BIH,Mostar,yes,LQMO,OMO,LQMO,,http://www.mostar-airport.ba/,https://en.wikipedia.org/wiki/Mostar_International_Airport,
|
|
||||||
4469,LQSA,large_airport,Sarajevo International Airport,43.8246,18.331499,1708,EU,BA,BA-BIH,Sarajevo,yes,LQSA,SJJ,LQSA,,http://www.sarajevo-airport.ba/,https://en.wikipedia.org/wiki/Sarajevo_International_Airport,
|
|
||||||
4470,LQTZ,large_airport,Tuzla International Airport,44.459858,18.723627,784,EU,BA,BA-BIH,Dubrave Gornje,yes,LQTZ,TZL,LQTZ,,http://www.tuzla-airport.ba/,https://en.wikipedia.org/wiki/Tuzla_International_Airport,
|
|
||||||
4472,LRBC,large_airport,Bacău George Enescu International Airport,46.5219,26.910299,607,EU,RO,RO-BC,Bacău,yes,LRBC,BCM,LRBC,,http://www.bacauairport.ro/,https://en.wikipedia.org/wiki/Bac%C4%83u_International_Airport,"Bacau, RoAF 95th Air Base"
|
|
||||||
333670,LRBV,large_airport,Brașov-Ghimbav International Airport,45.70558,25.522871,1740,EU,RO,RO-BV,Brașov (Ghimbav),yes,LRBV,GHV,LRBV,,https://brasovairport.ro/,https://en.wikipedia.org/wiki/Brașov-Ghimbav_International_Airport,Aeroportul Internațional Brașov-Ghimbav
|
|
||||||
4475,LRCK,large_airport,Mihail Kogălniceanu International Airport,44.362202,28.4883,353,EU,RO,RO-CT,Constanța,yes,LRCK,CND,LRCK,,http://www.mk-airport.ro/,https://en.wikipedia.org/wiki/Mihail_Kog%C4%83lniceanu_International_Airport,"Constanța, Mihail Kogalniceanu, RoAF 57th Air Base"
|
|
||||||
4476,LRCL,large_airport,Avram Iancu Cluj International Airport,46.786042,23.685733,1039,EU,RO,RO-CJ,Cluj-Napoca,yes,LRCL,CLJ,LRCL,,http://www.airportcluj.ro/,https://en.wikipedia.org/wiki/Cluj_International_Airport,Someşeni Airport
|
|
||||||
4479,LRCV,large_airport,Craiova International Airport,44.3181,23.888599,626,EU,RO,RO-DJ,Craiova,yes,LRCV,CRA,LRCV,,http://www.aeroportcraiova.ro/,https://en.wikipedia.org/wiki/Craiova_International_Airport,
|
|
||||||
4480,LRIA,large_airport,Iaşi International Airport,47.179633,27.621431,411,EU,RO,RO-IS,Iaşi,yes,LRIA,IAS,LRIA,,https://www.aeroport-iasi.ro/,https://en.wikipedia.org/wiki/Ia%C5%9Fi_International_Airport,"Iasi, Jassy"
|
|
||||||
4481,LROD,large_airport,Oradea International Airport,47.025299,21.9025,465,EU,RO,RO-BH,Oradea,yes,LROD,OMR,LROD,,https://www.aeroportoradea.ro/,https://en.wikipedia.org/wiki/Oradea_International_Airport,
|
|
||||||
4482,LROP,large_airport,Bucharest Henri Coandă International Airport,44.571792,26.103285,314,EU,RO,RO-IF,Otopeni,yes,LROP,OTP,LROP,,http://www.bucharestairports.ro/en/,https://en.wikipedia.org/wiki/Henri_Coand%C4%83_International_Airport,"BUH, Otopeni Airport, RoAF 90th Airlift Base"
|
|
||||||
4483,LRSB,large_airport,Sibiu International Airport,45.785841,24.086666,1496,EU,RO,RO-SB,Sibiu,yes,LRSB,SBZ,LRSB,,http://www.sibiuairport.ro/,https://en.wikipedia.org/wiki/Sibiu_International_Airport,
|
|
||||||
4485,LRSV,large_airport,Suceava Ștefan cel Mare International Airport,47.6875,26.354099,1375,EU,RO,RO-SV,Suceava,yes,LRSV,SCV,LRSV,,http://www.aeroportsuceava.ro/index_en.html,https://en.wikipedia.org/wiki/Suceava_Airport,
|
|
||||||
4488,LRTR,large_airport,Timișoara Traian Vuia International Airport,45.809898,21.3379,348,EU,RO,RO-TM,Timişoara,yes,LRTR,TSR,LRTR,,http://www.aerotim.ro/,https://en.wikipedia.org/wiki/Traian_Vuia_International_Airport,"RoAF 93rd Air Base, Giarmata Airport, Temesvár, Temeschburg, Temeswar, Temeschwar, Тимишоара, Темишвар, Temišvar, Banat"
|
|
||||||
4490,LSGG,large_airport,Geneva International Airport,46.238098,6.10895,1411,EU,CH,CH-GE,Geneva,yes,LSGG,GVA,LSGG,,https://www.gva.ch/,https://en.wikipedia.org/wiki/Geneva_Airport,Cointrin Airport
|
|
||||||
4505,LSZH,large_airport,Zürich Airport,47.458056,8.548056,1417,EU,CH,CH-ZH,Zurich,yes,LSZH,ZRH,LSZH,,http://www.zurich-airport.com/,https://en.wikipedia.org/wiki/Zurich_Airport,
|
|
||||||
317457,LTFM,large_airport,İstanbul Airport,41.274874,28.732136,325,EU,TR,TR-34,Istanbul,yes,LTFM,IST,LTFM,,https://www.istairport.com/,https://en.wikipedia.org/wiki/Istanbul_Airport,Arnavutköy
|
|
||||||
4571,LUKK,large_airport,Chişinău International Airport,46.92774,28.931704,399,EU,MD,MD-CU,Chişinău,yes,LUKK,RMO,LUKK,,http://www.airport.md/,https://en.wikipedia.org/wiki/Chi%C5%9Fin%C4%83u_International_Airport,KIV
|
|
||||||
4572,LWOH,large_airport,Ohrid St. Paul the Apostle Airport,41.18,20.7423,2313,EU,MK,MK-003,Ohrid,yes,LWOH,OHD,LWOH,,https://ohd.airports.com.mk/,https://en.wikipedia.org/wiki/Ohrid_Airport,
|
|
||||||
4573,LWSK,large_airport,Skopje International Airport,41.958087,21.622581,781,EU,MK,MK-008,Ilinden,yes,LWSK,SKP,LWSK,,http://skp.airports.com.mk/,https://en.wikipedia.org/wiki/Skopje_Airport,Petrovec
|
|
||||||
4574,LXGB,large_airport,Gibraltar Airport,36.151679,-5.34978,12,EU,GI,GI-U-A,Gibraltar,yes,LXGB,GIB,LXGB,,https://www.gibraltarairport.gi/,https://en.wikipedia.org/wiki/Gibraltar_Airport,
|
|
||||||
4610,LYBE,large_airport,Belgrade Nikola Tesla Airport,44.818401,20.309099,335,EU,RS,RS-00,Belgrade,yes,LYBE,BEG,LYBE,,http://www.beg.aero/,https://en.wikipedia.org/wiki/Belgrade_Nikola_Tesla_Airport,"Beograd, Surčin Airport"
|
|
||||||
4612,LYNI,large_airport,Niš Constantine the Great Airport,43.336538,21.856242,648,EU,RS,RS-20,Niš,yes,LYNI,INI,LYNI,,https://ini.aerodromisrbije.rs/en/,https://en.wikipedia.org/wiki/Ni%C5%A1_Constantine_the_Great_Airport,Aerodrom Konstantin Veliki Niš
|
|
||||||
4613,LYPG,large_airport,Podgorica Airport / Podgorica Golubovci Airbase,42.359402,19.2519,141,EU,ME,ME-16,Podgorica,yes,LYPG,TGD,LYPG,,https://montenegroairports.com/en/podgorica-airport/,https://en.wikipedia.org/wiki/Podgorica_Airport,"Podgorica Airbase, Golubovci Airbase, Аеродром Подгорица, Aerodrom Podgorica"
|
|
||||||
4617,LZIB,large_airport,M. R. Štefánik Airport,48.1702,17.2127,436,EU,SK,SK-BL,Bratislava,yes,LZIB,BTS,LZIB,,,https://en.wikipedia.org/wiki/M._R._%C5%A0tef%C3%A1nik_Airport,
|
|
||||||
6467,UKBB,large_airport,Boryspil International Airport,50.345001,30.894699,427,EU,UA,UA-32,Boryspil,no,UKBB,KBP,UKBB,,https://kbp.aero/en/,https://en.wikipedia.org/wiki/Boryspil_International_Airport,"Borispol, Міжнародний аеропорт ""Бориспіль"", Kyiv"
|
|
||||||
6474,UKFF,large_airport,Simferopol International Airport,45.0522,33.975101,639,EU,UA,UA-43,Simferopol,no,UKFF,SIP,UKFF,URFF,https://new.sipaero.ru/,https://en.wikipedia.org/wiki/Simferopol_International_Airport,
|
|
||||||
6481,UKLL,large_airport,Lviv International Airport,49.8125,23.9561,1071,EU,UA,UA-46,Lviv,no,UKLL,LWO,UKLL,,http://www.airport.lviv.ua/index.php?id=home&L=1,https://en.wikipedia.org/wiki/Lviv_International_Airport,"Міжнародний аеропорт ""Львів"""
|
|
||||||
6487,UKOO,large_airport,Odesa International Airport,46.427196,30.672649,172,EU,UA,UA-51,Odesa,no,UKOO,ODS,,,https://www.airport.od.ua/,https://en.wikipedia.org/wiki/Odessa_International_Airport,"ODS, UKOO, УКОО, Odesa Central, Міжнародний аеропорт Одеса, Odessa Airport"
|
|
||||||
6489,ULLI,large_airport,Pulkovo Airport,59.800301,30.262501,78,EU,RU,RU-SPE,St. Petersburg,yes,ULLI,LED,ULLI,,https://pulkovoairport.ru/,https://en.wikipedia.org/wiki/Pulkovo_Airport,"Аэропо́рт Пу́лково, Leningrad, Shosseynaya Airport, Saint Petersburg, Petrograd"
|
|
||||||
6490,ULMM,large_airport,Emperor Nicholas II Murmansk Airport,68.7817,32.750801,266,EU,RU,RU-MUR,Murmansk,yes,ULMM,MMK,ULMM,,http://www.airport-murmansk.ru/,https://en.wikipedia.org/wiki/Murmansk_Airport,"Аэропо́рт Му́рманска им. Николая II or Аэропорт Мурмаши, Мурманск"
|
|
||||||
6495,UMBB,large_airport,Brest International Airport,52.108138,23.89676,468,EU,BY,BY-BR,Brest,yes,UMBB,BQT,UMBB,,https://www.ban.by/filialy/brest,https://en.wikipedia.org/wiki/Brest_Airport,Аэропорт Брест
|
|
||||||
6498,UMKK,large_airport,Khrabrovo Airport,54.89161,20.598559,42,EU,RU,RU-KGD,Kaliningrad,yes,UMKK,KGD,UMKK,,https://kgdavia.ru/,https://en.wikipedia.org/wiki/Khrabrovo_Airport,"УМКК, Храброво"
|
|
||||||
6501,UMMS,large_airport,Minsk National Airport,53.888071,28.039964,670,EU,BY,BY-MI,Minsk,yes,UMMS,MSQ,UMMS,,http://airport.by/en,https://en.wikipedia.org/wiki/Minsk_National_Airport,"Нацыянальны аэрапорт, Minsk 2 Airport"
|
|
||||||
6507,UNNT,large_airport,Novosibirsk Tolmachevo Airport,55.019756,82.618675,365,EU,RU,RU-NVS,Novosibirsk,yes,UNNT,OVB,UNNT,,http://eng.tolmachevo.ru/,https://en.wikipedia.org/wiki/Tolmachevo_Airport,
|
|
||||||
6512,URKK,large_airport,Krasnodar Pashkovsky International Airport,45.034465,39.174215,118,EU,RU,RU-KDA,Krasnodar,yes,URKK,KRR,URKK,,https://krr.aero/en/,https://en.wikipedia.org/wiki/Pashkovsky_Airport,"Pashkovskiy Airport, Pashkovsky Airport"
|
|
||||||
6514,URMM,large_airport,Mineralnye Vody Airport,44.225101,43.081902,1054,EU,RU,RU-STA,Mineralnyye Vody,yes,URMM,MRV,URMM,,,https://en.wikipedia.org/wiki/Mineralnye_Vody_Airport,
|
|
||||||
326363,URRP,large_airport,Platov International Airport,47.493888,39.924722,213,EU,RU,RU-ROS,Rostov-on-Don,no,URRP,ROV,URRP,,,https://en.wikipedia.org/wiki/Platov_International_Airport,"УРРП, Международный аэропорт Платов"
|
|
||||||
6519,URSS,large_airport,Sochi International Airport,43.449902,39.9566,89,EU,RU,RU-KDA,Sochi,yes,URSS,AER,URSS,,https://aer.aero/en,https://en.wikipedia.org/wiki/Sochi_International_Airport,"Adler Airport, Black Sea, Международный Аэропорт Сочи"
|
|
||||||
6522,URWW,large_airport,Volgograd International Airport,48.78134,44.339194,482,EU,RU,RU-VGG,Volgograd,yes,URWW,VOG,URWW,,,https://en.wikipedia.org/wiki/Volgograd_International_Airport,"Gumrak Airport, Аэропорт Волгоград, Аэропорт Гумрак"
|
|
||||||
26395,UUDL,large_airport,Golden Ring Yaroslavl International Airport,57.560699,40.157398,287,EU,RU,RU-YAR,Tunoshna,yes,UUDL,IAR,UUDL,,,https://en.wikipedia.org/wiki/Tunoshna,
|
|
||||||
26396,UUEE,large_airport,Sheremetyevo International Airport,55.976858,37.41121,622,EU,RU,RU-MOS,Moscow,yes,UUEE,SVO,UUEE,,http://svo.aero/,https://en.wikipedia.org/wiki/Sheremetyevo_International_Airport,"УУЕЕ, Москва, MOW, Международный аэропорт Шереметьево, Alexander S. Pushkin"
|
|
||||||
26403,UWGG,large_airport,Nizhny Novgorod / Strigino International Airport,56.227351,43.785152,256,EU,RU,RU-NIZ,Nizhny Novgorod,yes,UWGG,GOJ,UWGG,,http://www.airportnn.ru/en/,https://en.wikipedia.org/wiki/Nizhny_Novgorod_International_Airport,"УВГГ, ЬВГГ, Нижний Новгород / Стригино"
|
|
||||||
42965,UWPS,large_airport,Saransk International Airport,54.12513,45.212257,676,EU,RU,RU-MO,Saransk,yes,UWPS,SKX,UWPS,,,https://en.wikipedia.org/wiki/Saransk_Airport,Аэропорт Саранск
|
|
||||||
26411,UWUU,large_airport,Ufa International Airport,54.557499,55.874401,449,EU,RU,RU-BA,Ufa,yes,UWUU,UFA,UWUU,,http://www.airportufa.ru/en/,https://en.wikipedia.org/wiki/Ufa_International_Airport,"УВУУ, Международный аэропорт Уфа"
|
|
||||||
26412,UWWW,large_airport,Kurumoch International Airport,53.504902,50.164299,477,EU,RU,RU-SAM,Samara,yes,UWWW,KUF,UWWW,,https://kuf.aero/en/,https://en.wikipedia.org/wiki/Kurumoch_International_Airport,"УВВВ, Самара / Курумоч"
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- "./public:/app/public"
|
- "./public:/app/public"
|
||||||
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
||||||
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
|
# Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
|
||||||
- "./db:/app/db"
|
- "./db:/app/db"
|
||||||
environment:
|
environment:
|
||||||
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
||||||
|
|||||||
+27
-52
@@ -5,8 +5,6 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { initialBearingDeg } from '../lib/geo';
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { windBurnFactor } from '../lib/sim/movement';
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
|
||||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
|
|
||||||
|
|
||||||
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
||||||
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
||||||
@@ -18,12 +16,9 @@ import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } f
|
|||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model a full tank covers the
|
|
||||||
// round trip, but the return leaves too little for another outbound
|
|
||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
|
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
||||||
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
||||||
// FUEL_PRICE_PER_L is imported from ../lib/game/catalog (v0.38.0) instead of mirrored here.
|
|
||||||
const REP_BASE = 1; // lib/sim/engine.ts
|
const REP_BASE = 1; // lib/sim/engine.ts
|
||||||
const REP_PER_KM = 0.0005;
|
const REP_PER_KM = 0.0005;
|
||||||
|
|
||||||
@@ -79,6 +74,14 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||||
@@ -118,7 +121,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
|
|||||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||||
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
let s = await getState(page);
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||||
|
// exact-fuel pins never see a worn or broken plane (broken planes burn ×1.5 more).
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
@@ -156,7 +163,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
|
|
||||||
|
|
||||||
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights + refuel
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights
|
||||||
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
@@ -188,8 +195,10 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
// package shows as loaded on the starter in the on-board section.
|
// package shows as loaded on the starter in the on-board section.
|
||||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||||
|
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||||
|
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||||
|
|
||||||
// FLY via the UI: click the destination name button in the on-board list.
|
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
await expect(flyBtn).toBeEnabled();
|
await expect(flyBtn).toBeEnabled();
|
||||||
await flyBtn.click();
|
await flyBtn.click();
|
||||||
@@ -241,15 +250,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
6,
|
6,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, rep-scaled (v0.38.0, ISS-072), deducted exactly.
|
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
||||||
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
|
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
||||||
expect(refuelRes.ok()).toBeTruthy();
|
|
||||||
s = await getState(page);
|
|
||||||
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
|
|
||||||
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
|
|
||||||
const fuelRate = repRate(s.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
|
||||||
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * (1 - fuelRate) * 100) / 100;
|
|
||||||
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
|
|
||||||
|
|
||||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
||||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
||||||
@@ -341,9 +343,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
// Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
||||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
||||||
await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(homeRes.ok()).toBeTruthy();
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
@@ -351,7 +353,7 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // the FAR round trip alone is ~30 s real time at 1000×
|
test.setTimeout(300_000); // self-heal + one short hop each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Load a package whose origin the plane is not at → 409.
|
// Load a package whose origin the plane is not at → 409.
|
||||||
@@ -389,37 +391,10 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
||||||
|
|
||||||
// Insufficient fuel (v0.16.0 weighted burn): a full tank covers the FAR round trip —
|
// ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
|
||||||
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
|
// auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
|
||||||
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
|
// insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
|
||||||
const s0 = await getState(page);
|
// app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
|
|
||||||
if (starter.fuel < starter.maxFuel) {
|
|
||||||
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long legs
|
|
||||||
}
|
|
||||||
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
|
|
||||||
expect(farRes.ok()).toBeTruthy();
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
|
||||||
|
|
||||||
const sFar = await getState(page);
|
|
||||||
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(atFar.locationIcao).toBe(FAR);
|
|
||||||
// The return leg fits: the weighted burn over it is less than what's left in the tank.
|
|
||||||
// ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
|
||||||
expect(backRes.ok()).toBeTruthy();
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
|
||||||
|
|
||||||
// …but the tank is now too light for another FAR leg.
|
|
||||||
const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
|
|
||||||
expect(againFar.status()).toBe(409);
|
|
||||||
expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
|
||||||
|
|
||||||
// Restore: refuel (the plane is already home).
|
|
||||||
await page.request.post('/api/planes/plane-starter/refuel');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
|
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
|||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue',
|
'/api/planes/{planeId}/queue',
|
||||||
'/api/planes/{planeId}/queue/cancel',
|
'/api/planes/{planeId}/queue/cancel',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
|
|||||||
+53
-38
@@ -15,6 +15,8 @@ const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights
|
|||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
|
// Amsterdam — a third airport, distinct from hub and destination (the queued stop in the ISS-077 test).
|
||||||
|
const QUEUE = 'EHAM';
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,6 +81,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
const s0 = await getState(page);
|
const s0 = await getState(page);
|
||||||
@@ -443,9 +453,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -500,55 +509,52 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
|
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
// ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
// destination — load it, queue EHAM while grounded (pre-built routes are allowed), then start the
|
||||||
|
// HUB→DEST leg via the API (same pattern as e2e/map-interactions.spec.ts). The hover must trace
|
||||||
|
// from DEST (where this flight lands) to EHAM — NOT from the plane's live mid-flight position.
|
||||||
|
const pkg = await createPackage(page, HUB, QUEUE);
|
||||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(load.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
|
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(queueRes.status()).toBe(200);
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||||||
|
const flyBody = (await fly.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
|
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
|
||||||
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
|
||||||
const hubLonLat = await airportLonLat(page, HUB);
|
// airport regardless of progress, so no mid-flight position window is needed at all.
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
|
||||||
if (!p || p.status !== 'flying') return false;
|
|
||||||
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
|
|
||||||
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
|
|
||||||
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
|
|
||||||
await postSim(page, { paused: true });
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
|
// Both trace endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
await fitToAirports(page, [HUB, DEST]);
|
await fitToAirports(page, [DEST, QUEUE]);
|
||||||
|
|
||||||
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
|
||||||
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
|
|
||||||
const destLonLat = await airportLonLat(page, DEST);
|
const destLonLat = await airportLonLat(page, DEST);
|
||||||
|
const queueLonLat = await airportLonLat(page, QUEUE);
|
||||||
|
const hubLonLat = await airportLonLat(page, HUB);
|
||||||
|
|
||||||
await row.hover();
|
await row.hover();
|
||||||
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
|
// ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's
|
||||||
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
|
// destination (the queued stop EHAM), and NO segment may start near the departure airport — that
|
||||||
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
// was the old live-position behavior right after takeoff. Same per-tile clipping/quantization
|
||||||
|
// caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||||
const segs = feats.filter(
|
const segs = feats.filter(
|
||||||
@@ -558,11 +564,14 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
if (!segs.length) return false;
|
if (!segs.length) return false;
|
||||||
const near = (a: [number, number], b: [number, number]) =>
|
const near = (a: [number, number], b: [number, number]) =>
|
||||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||||
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
|
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLonLat));
|
||||||
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLonLat);
|
||||||
|
});
|
||||||
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||||||
return startsAtLive && endsAtDest && noneStartsAtHub;
|
return startsAtDest && endsAtQueue && noneStartsAtHub;
|
||||||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the landing airport (DEST) to the queued stop' }).toBe(true);
|
||||||
|
|
||||||
await unhover(page);
|
await unhover(page);
|
||||||
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||||
@@ -570,9 +579,12 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
message: 'waiting for the hover route trace to clear after mouseleave',
|
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
|
// Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain),
|
||||||
// flying home afterwards leaves the starter clean for the other specs.
|
// resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM,
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
// not DEST — it never delivered), then fly home.
|
||||||
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancelRes.status()).toBe(200);
|
||||||
|
await setScale(page, SCALE); // also clears the pause above
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -580,6 +592,9 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
||||||
|
import { REPAIR_COST } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||||
|
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||||
|
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||||
|
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
|
||||||
|
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
||||||
|
// passes.
|
||||||
|
|
||||||
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
maxFuel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||||
|
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||||
|
async function setHealth(
|
||||||
|
page: Page,
|
||||||
|
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||||
|
): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||||
|
nearest = f.properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||||
|
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||||
|
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
if (starter.status === 'repairing') {
|
||||||
|
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||||
|
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying') {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.locationIcao !== HUB) {
|
||||||
|
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
|
// leg when cash allows — no top-up retry needed.
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||||
|
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||||
|
async function restoreStarter(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||||
|
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||||
|
await setHealth(page, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||||
|
// and leave a clean starter — full health, no active repair gate.
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await restoreStarter(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
|
||||||
|
|
||||||
|
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// (a) Force health to 42 — snapshot AND UI show it.
|
||||||
|
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.getByTestId(`plane-${STARTER}`);
|
||||||
|
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||||
|
.toContainText('42%');
|
||||||
|
|
||||||
|
// (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
|
||||||
|
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||||
|
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||||
|
.toHaveText('broken', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
|
||||||
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
|
||||||
|
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
|
||||||
|
|
||||||
|
s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.status).toBe('available'); // still grounded…
|
||||||
|
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
|
||||||
|
|
||||||
|
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
|
||||||
|
const cashBefore = s.account.cash;
|
||||||
|
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||||
|
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||||
|
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||||
|
// checked against the exact game clock the server used (a later GET would tick further).
|
||||||
|
const [res] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||||
|
repairBtn.click(),
|
||||||
|
]);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||||
|
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(repaired.status).toBe('repairing');
|
||||||
|
expect(repaired.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
|
||||||
|
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||||
|
.toHaveText('in repair', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||||
|
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||||
|
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.health === 100 ? true : false;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
@@ -93,6 +93,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
||||||
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||||
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
@@ -198,10 +206,8 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
await loadAndFly(page, starterLoc, HUB);
|
await loadAndFly(page, starterLoc, HUB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank,
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
// so a partially drained plane left by an earlier spec could not make it home.
|
await setFuelFull(page, 'plane-starter');
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
|
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|||||||
+12
-7
@@ -22,7 +22,7 @@ async function waitForAirportCount(page: Page): Promise<number> {
|
|||||||
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10);
|
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('page loads with the map and EU airport count', async ({ page }) => {
|
test('page loads with the map and world airport count', async ({ page }) => {
|
||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
await expect(page.getByTestId('map')).toBeVisible();
|
await expect(page.getByTestId('map')).toBeVisible();
|
||||||
const count = await waitForAirportCount(page);
|
const count = await waitForAirportCount(page);
|
||||||
@@ -33,6 +33,11 @@ test('header shows the app version sourced from package.json', async ({ page })
|
|||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
const header = page.getByTestId('app-header');
|
const header = page.getByTestId('app-header');
|
||||||
await expect(header).toContainText(`v${pkg.version}`);
|
await expect(header).toContainText(`v${pkg.version}`);
|
||||||
|
// ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md.
|
||||||
|
await expect(page.getByTestId('app-version')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
@@ -52,11 +57,11 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
|
|||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
|
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
|
||||||
// Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
|
// Nantes is used because the v0.9.0 three-pane layout narrows the map column (~560 px at the
|
||||||
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
|
// default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays
|
||||||
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
|
// isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and
|
||||||
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
|
// LFBD Bordeaux, ~39 px away at the initial zoom [10,52] z4 (verified against data/airports_world.csv),
|
||||||
// be overlapped by a neighbouring circle.
|
// so its marker cannot be overlapped by a neighbouring circle.
|
||||||
const pt = await page.evaluate(() => {
|
const pt = await page.evaluate(() => {
|
||||||
const map = (window as any).__flightMap;
|
const map = (window as any).__flightMap;
|
||||||
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
|
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
|
||||||
@@ -181,7 +186,7 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
|
|||||||
// Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every
|
// Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every
|
||||||
// source/layer not in the new style, so the airports layer must be re-established —
|
// source/layer not in the new style, so the airports layer must be re-established —
|
||||||
// count still shown and markers rendered again.
|
// count still shown and markers rendered again.
|
||||||
await expect(page.getByTestId('airport-count')).toContainText(/EU airports/);
|
await expect(page.getByTestId('airport-count')).toContainText(/world airports/);
|
||||||
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Clicking a marker after the switch opens exactly one popup (the click handler is
|
// Clicking a marker after the switch opens exactly one popup (the click handler is
|
||||||
|
|||||||
+3
-6
@@ -75,13 +75,10 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a
|
||||||
// heading and button render here…
|
// Refuel heading or row anymore.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
|
|
||||||
// …and neither the heading nor the row remains in the right pane.
|
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||||
|
|||||||
+73
-9
@@ -15,6 +15,7 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
|
|||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
queue: string[];
|
queue: string[];
|
||||||
@@ -69,6 +70,14 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
@@ -201,9 +210,8 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|
||||||
@@ -272,9 +280,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
|
// Full tank before departure — keeps the state canonical for whatever the cleanup does (ISS-082 hook).
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
||||||
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
||||||
@@ -350,13 +357,70 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
}).toBe(HUB);
|
}).toBe(HUB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('grounded plane with a pre-built queue draws its dotted route from its airport (ISS-077)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + map assertions only — no flight in this test
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// Pre-build a two-leg route while GROUNDED via the raw API (queuing is allowed from any state,
|
||||||
|
// ISS-043 part 1) — no package or flight needed. The dotted line must chain HUB → DEST → QUEUE.
|
||||||
|
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
|
expect(q1.status()).toBe(200);
|
||||||
|
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q2.status()).toBe(200);
|
||||||
|
|
||||||
|
// All chain endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
|
await fitToAirports(page, [HUB, DEST, QUEUE]);
|
||||||
|
|
||||||
|
const hubLL = await airportLonLat(page, HUB);
|
||||||
|
const destLL = await airportLonLat(page, DEST);
|
||||||
|
const queueLL = await airportLonLat(page, QUEUE);
|
||||||
|
const near = (a: [number, number], b: [number, number]) =>
|
||||||
|
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
geometry?: { type: string; coordinates: [number, number][] };
|
||||||
|
}>;
|
||||||
|
// Filter to real LineString segments — keeps this poll callback total. "None yet" → keep polling.
|
||||||
|
const segs = feats.filter(
|
||||||
|
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||||
|
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||||
|
);
|
||||||
|
if (!segs.length) return false;
|
||||||
|
// The chain HUB → DEST → QUEUE may be clipped into per-tile segments — check across all of them:
|
||||||
|
// some segment STARTS within EPS of the hub (the plane's airport, NOT a flight destination)…
|
||||||
|
const startsAtHub = segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||||||
|
// …some ENDS within EPS of the last queued airport…
|
||||||
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLL);
|
||||||
|
});
|
||||||
|
// …and the chain passes THROUGH the first queued airport — an interior joint point survives in
|
||||||
|
// whichever tile contains it (not guaranteed to be a segment endpoint after clipping).
|
||||||
|
const passesThroughDest = segs.some((f) => f.geometry.coordinates.some((c) => near(c, destLL)));
|
||||||
|
// Every rendered feature belongs to plane-starter.
|
||||||
|
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||||||
|
return startsAtHub && endsAtQueue && passesThroughDest && ownedByStarter;
|
||||||
|
}, { timeout: 15_000, message: `waiting for the dotted queue line ${HUB} → ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup: cancel both queued legs (index 0 twice — each cancel shifts the rest down) so the
|
||||||
|
// starter stays grounded at HUB with an empty queue. No flight happened, nothing else to reset.
|
||||||
|
const c1 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c1.status()).toBe(200);
|
||||||
|
const c2 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c2.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
|
// Full tank for a deterministic baseline — the engine auto-chains (and auto-refuels, ISS-082) the return leg regardless.
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
|
|
||||||
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|||||||
+13
-7
@@ -15,7 +15,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
|||||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
@@ -24,6 +24,14 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
const res = await page.request.post('/api/sim', { data: body });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
@@ -96,10 +104,8 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
|||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — a partially drained plane left by an earlier spec could not
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
// afford the cleanup round trip.
|
await setFuelFull(page, 'plane-starter');
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
|
||||||
expect(refuel.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
|
|
||||||
@@ -130,8 +136,8 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
expect(refuel.ok()).toBeTruthy();
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
|||||||
+65
-131
@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
import { initialBearingDeg } from '../lib/geo';
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { 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 { 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';
|
||||||
@@ -14,8 +14,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
|
|||||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||||
// abandon buttons (penalty preview from the same
|
// abandon buttons (penalty preview from the same
|
||||||
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
||||||
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
|
// the catalog + economy constants exposed in /api/state, and dispatch auto-refuels a low tank to
|
||||||
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
|
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
||||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
@@ -27,6 +27,7 @@ interface PlaneState {
|
|||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
|
health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,8 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
|
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
// Returns both endpoints' coordinates so exact-km fuel assertions can mirror lib/geo.ts.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
};
|
};
|
||||||
@@ -124,38 +126,7 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
nearest = f.properties;
|
nearest = f.properties;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nearest;
|
return { ...nearest, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
}
|
|
||||||
|
|
||||||
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
|
|
||||||
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
|
|
||||||
// full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
|
|
||||||
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
|
|
||||||
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
|
|
||||||
async function farthestFlyableToHub(
|
|
||||||
page: Page,
|
|
||||||
entry: CatalogEntry,
|
|
||||||
): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
|
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
|
||||||
};
|
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
|
||||||
const wHub = weatherFor(HUB);
|
|
||||||
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
|
||||||
for (const f of fc.features) {
|
|
||||||
if (f.properties.icao === HUB) continue;
|
|
||||||
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
|
||||||
const wDest = weatherFor(f.properties.icao);
|
|
||||||
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
|
|
||||||
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
|
||||||
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
|
|
||||||
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
|
|
||||||
if (!farthest || d > farthest.d) {
|
|
||||||
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
|
|
||||||
return { ...farthest, hubLat: hub.lat, hubLon: hub.lon };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||||
@@ -184,7 +155,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
|||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
const s0 = await getState(page);
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||||
|
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
@@ -200,14 +175,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||||
// fast-forwards instead of taking real-time hours.
|
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
|
||||||
|
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
let flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
if (!flyHome.ok()) {
|
|
||||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
|
||||||
flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
|
||||||
}
|
|
||||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
@@ -357,7 +328,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
|
test('market buy/sell render with previews from the catalog in /api/state', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||||
|
|
||||||
@@ -393,117 +364,80 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
||||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
|
|
||||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Refuel through the pane: the flight genuinely burned fuel…
|
|
||||||
if (starter.fuel < starter.maxFuel) {
|
|
||||||
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'full'.
|
|
||||||
const fuelRate = repRate(body.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
|
||||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
|
||||||
const cashBefore = body.account.cash;
|
|
||||||
|
|
||||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
|
||||||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
|
||||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
|
||||||
await page.getByTestId('market-refuel').click();
|
|
||||||
|
|
||||||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const b = await getState(page);
|
|
||||||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
|
||||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
|
||||||
.toBe(true);
|
|
||||||
const after = await getState(page);
|
|
||||||
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
|
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead. This test pins the new
|
||||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
// behavior end-to-end: drop a grounded starter to low fuel via the hook, fly a leg, and assert the
|
||||||
|
// cash delta equals the EXACT rep-discounted top-up cost (computed from the snapshot + real lib
|
||||||
|
// helpers) while the post-flight landed fuel matches the weighted burn from the topped start value.
|
||||||
|
test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement and deducts exactly', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one short flight each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||||||
|
|
||||||
// Deterministic baseline: a full tank.
|
// Drop the grounded starter to a low, deterministic fuel level via the test hook — well below the
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
// 150%-of-requirement target even for the shortest dataset leg (EDFH, ~93 km), so a top-up is guaranteed.
|
||||||
|
const LOW_FUEL = 50;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: LOW_FUEL } })).status()).toBe(200);
|
||||||
|
|
||||||
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
|
// Nearest airport to the hub → short leg, fast at scale 1000.
|
||||||
// exceeds what's left on arrival.
|
const nearest = await nearestToHub(page);
|
||||||
const farthest = await farthestFlyableToHub(page, entry);
|
const pre = await getState(page);
|
||||||
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
|
const starter = pre.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
|
||||||
|
|
||||||
// Fly there (delivers pkg1 on arrival) — the tank is now partial.
|
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200);
|
// wind-adjusted requirement, 150% margin capped at tank capacity.
|
||||||
|
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
const required = requiredFuelFor(
|
||||||
|
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||||||
|
entry.emptyWeightKg,
|
||||||
|
entry.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
const target = Math.min(entry.maxFuel, Math.max(LOW_FUEL, required * 1.5)); // ISS-082: 150% margin, capped at tank capacity
|
||||||
|
const liters = Math.max(0, target - LOW_FUEL);
|
||||||
|
expect(liters).toBeGreaterThan(0); // the low tank genuinely needs a top-up
|
||||||
|
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||||||
|
const cashBefore = pre.account.cash;
|
||||||
|
|
||||||
|
// Dispatch via the API — the response snapshot is authoritative at the action's clock.
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(200); // auto-refuel made the low tank flyable…
|
||||||
|
const post = (await flyRes.json()) as StateBody;
|
||||||
|
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // …departing with exactly the topped level
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||||
|
|
||||||
|
// Fast-forward to arrival; the landed fuel is the weighted burn from the TOPPED start value.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const b = await getState(page);
|
const b = await getState(page);
|
||||||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === farthest.icao;
|
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||||||
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` })
|
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for.
|
const landed = await getState(page);
|
||||||
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
|
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
|
||||||
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
|
||||||
|
6,
|
||||||
// Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
|
|
||||||
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
|
|
||||||
const pre = await getState(page);
|
|
||||||
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
|
|
||||||
const cashBefore = pre.account.cash;
|
|
||||||
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
|
|
||||||
const wFar = weatherFor(farthest.icao);
|
|
||||||
const required = requiredFuelFor(
|
|
||||||
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
|
|
||||||
entry.emptyWeightKg,
|
|
||||||
entry.maxFuel,
|
|
||||||
km,
|
|
||||||
);
|
);
|
||||||
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
|
|
||||||
const liters = Math.max(0, target - fuelBefore);
|
|
||||||
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
|
|
||||||
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'forFlight'.
|
|
||||||
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
|
||||||
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
|
||||||
|
|
||||||
await page.goto('/');
|
// Restore: fly home (auto-refuel covers the return leg) and top the tank back up for later specs.
|
||||||
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
|
|
||||||
await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 });
|
|
||||||
// Capture the endpoint's OWN response — the full post-action snapshot at the action's clock.
|
|
||||||
const [res] = await Promise.all([
|
|
||||||
page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }),
|
|
||||||
refuelBtn.click(),
|
|
||||||
]);
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
const post = (await res.json()) as StateBody;
|
|
||||||
|
|
||||||
// Cash dropped by exactly L × price and fuel rose by exactly L.
|
|
||||||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
|
||||||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
|
||||||
|
|
||||||
// Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
|
|
||||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
|
||||||
|
|
||||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
|
||||||
// 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
|
|
||||||
// the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
timeout: 240_000,
|
timeout: 120_000,
|
||||||
message: 'waiting for plane-starter to land back at the hub',
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
// Top the tank back up so later specs start with a full tank.
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||||||
|
|||||||
@@ -80,12 +80,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
||||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
let flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
if (!flyHome.ok()) {
|
// leg when cash allows — no top-up retry needed.
|
||||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect((await page.request.post(`/api/planes/${plane.id}/refuel`)).status()).toBe(200);
|
|
||||||
flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
|
||||||
}
|
|
||||||
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-73
@@ -5,15 +5,13 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { initialBearingDeg } from '../lib/geo';
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { windBurnFactor } from '../lib/sim/movement';
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
|
||||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
|
|
||||||
|
|
||||||
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
||||||
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
|
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
|
||||||
//
|
//
|
||||||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||||
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||||
@@ -171,6 +169,14 @@ async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
||||||
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||||||
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||||||
@@ -244,13 +250,10 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (p.locationIcao !== HUB) {
|
if (p.locationIcao !== HUB) {
|
||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
|
// leg when cash allows — no top-up retry needed.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
if (flyHome.status() === 409) {
|
|
||||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
|
||||||
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
|
|
||||||
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
|
||||||
}
|
|
||||||
expect(flyHome.status()).toBe(200);
|
expect(flyHome.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||||||
@@ -275,8 +278,11 @@ async function sellNonStarters(page: Page): Promise<void> {
|
|||||||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||||
// no cargo on board — even after an interrupted previous run.
|
// no cargo on board — even after an interrupted previous run.
|
||||||
async function resetFleet(page: Page): Promise<void> {
|
async function resetFleet(page: Page): Promise<void> {
|
||||||
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
await sellNonStarters(page);
|
await sellNonStarters(page);
|
||||||
await healPlaneHome(page, STARTER);
|
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||||
await abandonLeftoverCargo(page, STARTER);
|
await abandonLeftoverCargo(page, STARTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +353,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
const secondId = await buySecondPlaneViaUi(page);
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
// Full tanks for both — the starter's may be partial from an earlier run.
|
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, STARTER);
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||||
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||||
@@ -399,8 +405,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||||
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
|
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||||||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||||||
@@ -415,64 +421,8 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
|
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||||
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
|
// its absence is pinned in e2e/panes.spec.ts instead.
|
||||||
const secondId = await buySecondPlaneViaUi(page);
|
|
||||||
|
|
||||||
// Partial tanks: a short hop each, to different destinations.
|
|
||||||
const [a, b] = await twoNearestToHub(page);
|
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
|
||||||
|
|
||||||
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
await expect
|
|
||||||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
|
||||||
timeout: 240_000,
|
|
||||||
message: 'waiting for the first plane to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
await expect
|
|
||||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
|
||||||
timeout: 240_000,
|
|
||||||
message: 'waiting for both planes to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const pre = await getState(page);
|
|
||||||
const starter = pre.planes.find((p) => p.id === STARTER)!;
|
|
||||||
const second = pre.planes.find((p) => p.id === secondId)!;
|
|
||||||
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
|
|
||||||
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
|
|
||||||
|
|
||||||
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
|
|
||||||
const refuelSelect = page.getByTestId('market-refuel-plane');
|
|
||||||
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
|
||||||
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
|
||||||
|
|
||||||
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'),
|
|
||||||
// rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation.
|
|
||||||
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
|
||||||
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
|
||||||
await refuelSelect.selectOption(secondId);
|
|
||||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
|
||||||
|
|
||||||
await page.getByTestId('market-refuel').click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const s = await getState(page);
|
|
||||||
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
|
|
||||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const post = await getState(page);
|
|
||||||
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
|
|
||||||
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||||||
@@ -524,7 +474,7 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
const [a, b] = await twoNearestToHub(page);
|
const [a, b] = await twoNearestToHub(page);
|
||||||
|
|
||||||
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
await setFuelFull(page, STARTER);
|
||||||
|
|
||||||
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||||
|
|||||||
+22
-13
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports';
|
import { assertUniqueIcaos, filterWorldAirports, parseCsv, toGeoJson, airportsHandler } from './airports';
|
||||||
|
|
||||||
const HEADER =
|
const HEADER =
|
||||||
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
|
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
|
||||||
@@ -53,25 +53,34 @@ describe('parseCsv', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterEuAirports', () => {
|
describe('filterWorldAirports (v0.44.0 — world dataset, no continent check)', () => {
|
||||||
it('keeps EU fixed-wing airports and seaplane bases, drops the rest', () => {
|
it('keeps fixed-wing airports and seaplane bases from every continent, drops the rest by type', () => {
|
||||||
const rows = parseCsv(
|
const rows = parseCsv(
|
||||||
[
|
[
|
||||||
HEADER,
|
HEADER,
|
||||||
row({ ident: 'EU-SMALL', type: 'small_airport' }),
|
row({ ident: 'EU-SMALL', type: 'small_airport' }),
|
||||||
row({ ident: 'EU-MED', type: 'medium_airport' }),
|
row({ ident: 'NA-LRG', continent: 'NA', type: 'large_airport' }),
|
||||||
row({ ident: 'EU-LRG', type: 'large_airport' }),
|
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
|
||||||
row({ ident: 'EU-SEA', type: 'seaplane_base' }),
|
row({ ident: 'EU-SEA', type: 'seaplane_base' }),
|
||||||
row({ ident: 'EU-LEGACY', type: 'airport' }),
|
row({ ident: 'EU-LEGACY', type: 'airport' }),
|
||||||
row({ ident: 'EU-Helio', type: 'heliport' }),
|
row({ ident: 'NA-Helio', continent: 'NA', type: 'heliport' }),
|
||||||
row({ ident: 'EU-CLOSED', type: 'closed' }),
|
row({ ident: 'AS-CLOSED', continent: 'AS', type: 'closed' }),
|
||||||
row({ ident: 'EU-BALLOON', type: 'balloonport' }),
|
|
||||||
row({ ident: 'NA-SMALL', continent: 'NA' }),
|
|
||||||
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
const kept = filterEuAirports(rows).map((r) => r.ident);
|
const kept = filterWorldAirports(rows).map((r) => r.ident);
|
||||||
expect(kept.sort()).toEqual(['EU-LEGACY', 'EU-LRG', 'EU-MED', 'EU-SEA', 'EU-SMALL']);
|
expect(kept.sort()).toEqual(['AS-LRG', 'EU-LEGACY', 'EU-SEA', 'EU-SMALL', 'NA-LRG']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertUniqueIcaos (v0.44.0 load-time guard)', () => {
|
||||||
|
it('passes a collection with unique ICACOs and throws on the first duplicate', () => {
|
||||||
|
const ok = toGeoJson(parseCsv([HEADER, row({ ident: 'AAAA', icao_code: 'AAAA' }), row({ ident: 'BBBB', icao_code: 'BBBB' })].join('\n')));
|
||||||
|
expect(() => assertUniqueIcaos(ok)).not.toThrow();
|
||||||
|
|
||||||
|
// icao_code empty → the effective key is the ident fallback (toGeoJson), so a duplicate
|
||||||
|
// ident must trip the guard too.
|
||||||
|
const dup = toGeoJson(parseCsv([HEADER, row({ ident: 'CCCC', icao_code: '' }), row({ ident: 'CCCC', icao_code: '', latitude_deg: '51.0' })].join('\n')));
|
||||||
|
expect(() => assertUniqueIcaos(dup)).toThrow(/duplicate ICAO.*CCCC/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +111,7 @@ describe('toGeoJson', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('/api/airports handler', () => {
|
describe('/api/airports handler', () => {
|
||||||
it('returns a valid GeoJSON FeatureCollection of EU airports', async () => {
|
it('returns a valid GeoJSON FeatureCollection of world airports', async () => {
|
||||||
const res = await airportsHandler();
|
const res = await airportsHandler();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get('content-type')).toContain('application/json');
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
|||||||
+20
-5
@@ -52,11 +52,13 @@ export function parseCsv(text: string): CsvRow[] {
|
|||||||
// The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a
|
// The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a
|
||||||
// single "airport" type + category column) — both spellings are accepted. Heliports are
|
// single "airport" type + category column) — both spellings are accepted. Heliports are
|
||||||
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are
|
// excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are
|
||||||
// irrelevant to an airline-ops game.
|
// irrelevant to an airline-ops game. The committed dataset is pre-curated to large_airport
|
||||||
|
// only (scripts/build-world-airports.mjs); this check stays as a cheap guard against an
|
||||||
|
// uncurated dump being swapped in.
|
||||||
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
||||||
|
|
||||||
export function filterEuAirports(rows: CsvRow[]): CsvRow[] {
|
export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
|
||||||
return rows.filter((r) => r.continent === 'EU' && INCLUDED_TYPES.has(r.type));
|
return rows.filter((r) => INCLUDED_TYPES.has(r.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AirportProperties {
|
export interface AirportProperties {
|
||||||
@@ -93,13 +95,26 @@ export function toGeoJson(rows: CsvRow[]): GeoJsonFeatureCollection {
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three consumers key features by ICAO with silent-overwrite semantics (the map's airport
|
||||||
|
// index, the route/queue GeoJSON builders and the sim's airport lookups) — a duplicate would
|
||||||
|
// corrupt state without any error, so fail loud at load instead.
|
||||||
|
export function assertUniqueIcaos(fc: GeoJsonFeatureCollection): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (seen.has(f.properties.icao)) throw new Error(`duplicate ICAO in airport dataset: ${f.properties.icao}`);
|
||||||
|
seen.add(f.properties.icao);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cached: GeoJsonFeatureCollection | null = null;
|
let cached: GeoJsonFeatureCollection | null = null;
|
||||||
|
|
||||||
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
|
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const file = path.join(process.cwd(), 'data', 'airports_eu.csv');
|
const file = path.join(process.cwd(), 'data', 'airports_world.csv');
|
||||||
const text = await fs.readFile(file, 'utf8');
|
const text = await fs.readFile(file, 'utf8');
|
||||||
cached = toGeoJson(filterEuAirports(parseCsv(text)));
|
const fc = toGeoJson(filterWorldAirports(parseCsv(text)));
|
||||||
|
assertUniqueIcaos(fc);
|
||||||
|
cached = fc;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS planes (
|
|||||||
lat REAL NOT NULL,
|
lat REAL NOT NULL,
|
||||||
lon REAL NOT NULL,
|
lon REAL NOT NULL,
|
||||||
departure_sim_time INTEGER,
|
departure_sim_time INTEGER,
|
||||||
queue TEXT NOT NULL DEFAULT '[]'
|
queue TEXT NOT NULL DEFAULT '[]',
|
||||||
|
health REAL NOT NULL DEFAULT 100,
|
||||||
|
repair_until_sim_time INTEGER
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS packages (
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -88,6 +90,14 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('packages').has('special')) {
|
if (!cols('packages').has('special')) {
|
||||||
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
||||||
}
|
}
|
||||||
|
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
|
||||||
|
if (!cols('planes').has('health')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
|
||||||
|
}
|
||||||
|
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
|
||||||
|
if (!cols('planes').has('repair_until_sim_time')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
||||||
|
}
|
||||||
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||||
|
|||||||
+9
-1
@@ -7,7 +7,9 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|||||||
export const planes = sqliteTable('planes', {
|
export const planes = sqliteTable('planes', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
model: text('model').notNull(),
|
model: text('model').notNull(),
|
||||||
status: text('status', { enum: ['available', 'flying'] }).notNull(),
|
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
||||||
|
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||||
|
status: text('status', { enum: ['available', 'flying', 'repairing'] }).notNull(),
|
||||||
fuel: real('fuel').notNull(),
|
fuel: real('fuel').notNull(),
|
||||||
maxFuel: real('max_fuel').notNull(),
|
maxFuel: real('max_fuel').notNull(),
|
||||||
locationIcao: text('location_icao').notNull(),
|
locationIcao: text('location_icao').notNull(),
|
||||||
@@ -24,6 +26,12 @@ export const planes = sqliteTable('planes', {
|
|||||||
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||||
queue: text('queue').notNull().default('[]'),
|
queue: text('queue').notNull().default('[]'),
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
||||||
|
// landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
|
||||||
|
health: real('health').notNull().default(100),
|
||||||
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
||||||
|
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
||||||
|
repairUntilSimTime: integer('repair_until_sim_time'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50.03,
|
lat: 50.03,
|
||||||
lon: 8.56,
|
lon: 8.56,
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+149
-168
@@ -16,6 +16,8 @@ import {
|
|||||||
MAX_PLANE_DISCOUNT,
|
MAX_PLANE_DISCOUNT,
|
||||||
PLANE_DISCOUNT_PER_REP,
|
PLANE_DISCOUNT_PER_REP,
|
||||||
PLANE_TYPES,
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
repRate,
|
repRate,
|
||||||
SELL_FRACTION,
|
SELL_FRACTION,
|
||||||
} from './catalog';
|
} from './catalog';
|
||||||
@@ -29,7 +31,7 @@ import {
|
|||||||
ignorePackage,
|
ignorePackage,
|
||||||
loadPackage,
|
loadPackage,
|
||||||
queueLeg,
|
queueLeg,
|
||||||
refuelPlane,
|
repairPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
@@ -227,11 +229,107 @@ describe('flyPlane', () => {
|
|||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
||||||
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
|
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||||
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
|
||||||
|
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
|
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
|
||||||
|
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
||||||
|
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
|
||||||
|
|
||||||
|
// Wind-adjusted requirement for the hub → LEMD leg — computed with the SAME helpers flyPlane uses,
|
||||||
|
// so the expectations stay exact without hardcoded literals.
|
||||||
|
async function adjustedRequiredForHubToLemd() {
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
const w = weatherFor(HUB_ICAO);
|
||||||
|
return requiredFuelFor(
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('auto-refuels on dispatch: tops up to min(maxFuel, max(fuel, 1.5 × required)) and deducts the rep-discounted cost', async () => {
|
||||||
|
const inst = await freshDb('fly-autorefuel.db'); // EDDF → LEMD
|
||||||
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
|
const startFuel = Math.round(required / 2); // partial tank below the requirement
|
||||||
|
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
|
||||||
|
await inst.db.update(schema.account).set({ reputation: 30 });
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||||
|
|
||||||
|
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
||||||
|
const target = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||||
|
const expectedCost = Math.round((target - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
expect(expectedCost).toBeGreaterThan(0); // the top-up genuinely costs something
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => {
|
||||||
|
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
|
||||||
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
|
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
|
||||||
|
const inst = await freshDb('fly-autorefuel-poor.db'); // EDDF → LEMD
|
||||||
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
|
const startFuel = Math.round(required / 2);
|
||||||
|
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await inst.db.update(schema.account).set({ cash: 0 });
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
|
expect(plane.fuel).toBe(startFuel); // …with the fuel UNCHANGED — nothing was mutated
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects a leg that does not fit even a full tank with 409 (the auto-refuel backstop)', async () => {
|
||||||
|
const inst = await freshDb('fly-autorefuel-nofit.db');
|
||||||
|
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
|
||||||
|
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -584,198 +682,81 @@ describe('sellPlane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('refuelPlane', () => {
|
describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
|
||||||
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
|
||||||
const inst = await freshDb('refuel-ok.db');
|
const inst = await freshDb('repair-ok.db');
|
||||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||||
|
|
||||||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100);
|
expect(cost).toBe(REPAIR_COST);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
expect(plane.status).toBe('repairing');
|
||||||
|
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||||
|
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a free no-op when the tank is already full', async () => {
|
it('is a free no-op when health is already full', async () => {
|
||||||
const inst = await freshDb('refuel-full.db'); // seeded starter is full
|
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||||
|
|
||||||
expect(cost).toBe(0);
|
expect(cost).toBe(0);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // not grounded…
|
||||||
|
expect(plane.health).toBe(100);
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('refuel-flying.db');
|
const inst = await freshDb('repair-flying.db');
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects the fill-up when cash is below the cost with 409', async () => {
|
it('rejects a plane that is already repairing with 409', async () => {
|
||||||
const inst = await freshDb('refuel-poor.db');
|
const inst = await freshDb('repair-busy.db');
|
||||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes)
|
||||||
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
|
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||||
const inst = await freshDb('refuel-rep.db');
|
const inst = await freshDb('repair-blocks.db');
|
||||||
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await inst.db.update(schema.account).set({ reputation: 30 });
|
await repairPlane(inst, 'plane-starter');
|
||||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
|
||||||
expect(rate).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// 'full': exact discounted cost, tank math untouched.
|
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
await inst.db.insert(schema.planes).values({
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
|
});
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||||
|
});
|
||||||
|
|
||||||
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
|
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||||
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
|
const inst = await freshDb('repair-poor.db');
|
||||||
await loadPackage(inst, 'p-ff-r', 'plane-starter');
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const required = await adjustedRequiredForHubToLemd();
|
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||||
const startFuel = Math.round(required / 2);
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||||
|
|
||||||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
|
||||||
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
|
||||||
const plane2 = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('refuel-unknown.db');
|
const inst = await freshDb('repair-unknown.db');
|
||||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||||
});
|
|
||||||
|
|
||||||
// --- forFlight mode (v0.16.0) ---
|
|
||||||
|
|
||||||
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
|
|
||||||
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
|
|
||||||
async function adjustedRequiredForHubToLemd() {
|
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
const w = weatherFor(HUB_ICAO);
|
|
||||||
return requiredFuelFor(
|
|
||||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
|
||||||
type.emptyWeightKg,
|
|
||||||
type.maxFuel,
|
|
||||||
km,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
|
|
||||||
const inst = await freshDb('refuel-forflight-ok.db');
|
|
||||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
|
||||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
|
||||||
|
|
||||||
const required = await adjustedRequiredForHubToLemd();
|
|
||||||
|
|
||||||
// Partial tank below the requirement so L > 0.
|
|
||||||
const startFuel = Math.round(required / 2);
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
|
||||||
|
|
||||||
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
|
|
||||||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
|
||||||
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-flyafter.db');
|
|
||||||
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
|
||||||
await loadPackage(inst, 'p-fa', 'plane-starter');
|
|
||||||
|
|
||||||
const required = await adjustedRequiredForHubToLemd();
|
|
||||||
|
|
||||||
// Empty-ish tank: well below the leg requirement.
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
|
||||||
|
|
||||||
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane.status).toBe('flying');
|
|
||||||
expect(plane.destIcao).toBe('LEMD');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
|
||||||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
|
||||||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
|
||||||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
|
||||||
|
|
||||||
expect(cost).toBe(0);
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
|
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
|
|
||||||
await insertOpenPackage(inst, 'p-x');
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
|
|
||||||
|
|
||||||
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
|
|
||||||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
|
||||||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight rejects an expired package with 409', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-expired.db');
|
|
||||||
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight rejects an unknown package id with 404', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-unknown.db');
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight rejects a missing packageId with 400', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-nopkg.db');
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
|
|
||||||
const inst = await freshDb('refuel-ff-nofit.db');
|
|
||||||
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
|
|
||||||
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
|
|
||||||
await loadPackage(inst, 'p-far', 'plane-starter');
|
|
||||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
await expectActionError(
|
|
||||||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
|
||||||
409,
|
|
||||||
/does not fit/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-70
@@ -15,6 +15,8 @@ import {
|
|||||||
MAX_PLANE_DISCOUNT,
|
MAX_PLANE_DISCOUNT,
|
||||||
PLANE_DISCOUNT_PER_REP,
|
PLANE_DISCOUNT_PER_REP,
|
||||||
PLANE_TYPES,
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
repRate,
|
repRate,
|
||||||
SELL_FRACTION,
|
SELL_FRACTION,
|
||||||
} from './catalog';
|
} from './catalog';
|
||||||
@@ -22,9 +24,9 @@ import { abandonPenalty } from './penalty';
|
|||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
|
|
||||||
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
||||||
// load a package onto a grounded plane at its origin, fly to any airport, deliver
|
// load a package onto a grounded plane at its origin, fly to any airport (dispatch
|
||||||
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
|
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
|
||||||
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
|
// grounded; plus buy/sell planes. Each function validates and mutates ONE throwaway-safe GameDb instance
|
||||||
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
||||||
// validate + mutate are atomic under the sim lock. Validation failures throw
|
// validate + mutate are atomic under the sim lock. Validation failures throw
|
||||||
// GameActionError carrying the HTTP status:
|
// GameActionError carrying the HTTP status:
|
||||||
@@ -99,7 +101,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
|
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
|
||||||
// loaded packages' destinations or pure repositioning. On arrival the engine
|
// loaded packages' destinations or pure repositioning. On arrival the engine
|
||||||
// delivers whatever is bound for that airport. Validation: plane 'available',
|
// delivers whatever is bound for that airport. Validation: plane 'available',
|
||||||
// destination in the airport dataset, enough fuel for the leg.
|
// destination in the airport dataset, enough fuel for the leg. ISS-082 (v0.42.0):
|
||||||
|
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
|
||||||
|
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
|
||||||
|
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
|
||||||
|
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
||||||
|
// 409s before any refuel math, so fuel and cash stay untouched.
|
||||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
||||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
@@ -107,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);
|
||||||
@@ -121,8 +131,22 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
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));
|
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);
|
||||||
if (requiredL > plane.fuel) {
|
|
||||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
||||||
|
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
|
||||||
|
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
|
||||||
|
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||||
|
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||||
|
const liters = Math.max(0, target - plane.fuel);
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
|
if (cost > 0 && account.cash < cost) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||||
|
}
|
||||||
|
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
|
||||||
|
if (requiredL > target) {
|
||||||
|
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} L`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAction() ticked first, so this is current game time.
|
// runAction() ticked first, so this is current game time.
|
||||||
@@ -136,8 +160,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
departureSimTime: sim.clockMs,
|
departureSimTime: sim.clockMs,
|
||||||
lat: origin.lat,
|
lat: origin.lat,
|
||||||
lon: origin.lon,
|
lon: origin.lon,
|
||||||
|
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
|
if (cost > 0) {
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||||
|
}
|
||||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||||
@@ -284,6 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
lat: home.lat,
|
lat: home.lat,
|
||||||
lon: home.lon,
|
lon: home.lon,
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
|
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||||
});
|
});
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
@@ -326,82 +355,39 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
return { payout };
|
return { payout };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||||||
|
|
||||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
|
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
|
||||||
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
|
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
|
||||||
// requirement per the weighted burn model, capped at tank capacity, independent of how
|
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
|
||||||
// much is already in the tank.
|
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||||||
export interface RefuelOptions {
|
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||||||
mode?: 'full' | 'forFlight';
|
// plane; 409 not grounded / insufficient funds.
|
||||||
packageId?: string;
|
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||||
}
|
|
||||||
|
|
||||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
|
|
||||||
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
|
|
||||||
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
|
|
||||||
// leg doesn't fit a full tank).
|
|
||||||
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> {
|
|
||||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||||
}
|
}
|
||||||
|
// Free no-op when the plane is already at full health — nothing to repair.
|
||||||
|
if (plane.health >= 100) return { cost: 0 };
|
||||||
|
|
||||||
const mode = opts.mode ?? 'full';
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
let targetFuel: number; // tank level after the refuel
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
if (mode === 'forFlight') {
|
|
||||||
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
|
|
||||||
|
|
||||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
|
|
||||||
const pkg = pkgRows[0];
|
|
||||||
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
|
|
||||||
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
|
|
||||||
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
|
|
||||||
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = PLANE_TYPES[plane.model];
|
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
|
||||||
const origin = await airportByIcao(plane.locationIcao);
|
|
||||||
const dest = await airportByIcao(pkg.destIcao);
|
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
|
||||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
|
|
||||||
const w = weatherFor(plane.locationIcao);
|
|
||||||
const requiredL = requiredFuelFor(
|
|
||||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
|
|
||||||
type.emptyWeightKg,
|
|
||||||
type.maxFuel,
|
|
||||||
distanceKm,
|
|
||||||
);
|
|
||||||
if (requiredL > plane.maxFuel) {
|
|
||||||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
|
||||||
}
|
|
||||||
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
|
|
||||||
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
|
|
||||||
// → free no-op, tank untouched.
|
|
||||||
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
|
||||||
} else {
|
|
||||||
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
|
||||||
targetFuel = plane.maxFuel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const liters = Math.max(0, targetFuel - plane.fuel);
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!account) throw new Error('account row missing');
|
if (!account) throw new Error('account row missing');
|
||||||
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
|
if (account.cash < REPAIR_COST) {
|
||||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||||
if (cost > 0 && account.cash < cost) {
|
|
||||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
|
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
|
||||||
if (cost > 0) {
|
await inst.db.update(schema.planes)
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
}
|
.where(eq(schema.planes.id, planeId));
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { cost };
|
return { cost: REPAIR_COST };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Route helper (response convention, v0.8.0) ---
|
// --- Route helper (response convention, v0.8.0) ---
|
||||||
|
|||||||
+13
-2
@@ -100,7 +100,8 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
|||||||
// The plane the player starts with.
|
// The plane the player starts with.
|
||||||
export const STARTER_MODEL = 'atr72';
|
export const STARTER_MODEL = 'atr72';
|
||||||
|
|
||||||
// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this.
|
// Jet-A1 fuel price in €/liter (v0.8.0): top-up cost = liters × this — applied to the automatic
|
||||||
|
// dispatch top-ups since ISS-082 (manual refuel removed).
|
||||||
export const FUEL_PRICE_PER_L = 0.8;
|
export const FUEL_PRICE_PER_L = 0.8;
|
||||||
|
|
||||||
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
||||||
@@ -122,9 +123,19 @@ export const FUEL_DISCOUNT_PER_REP = 0.005;
|
|||||||
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
|
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
|
||||||
export const MAX_FUEL_DISCOUNT = 0.2;
|
export const MAX_FUEL_DISCOUNT = 0.2;
|
||||||
|
|
||||||
|
// --- Maintenance (v0.39.0, ISS-069) ---
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
|
||||||
|
export const WEAR_PER_KM = 0.0005;
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
||||||
|
export const REPAIR_COST = 1_500;
|
||||||
|
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
||||||
|
export const REPAIR_DURATION_MS = 3 * 86_400_000;
|
||||||
|
|
||||||
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
||||||
// id used by buy/dispatch, `label` the display name — clients compute all market
|
// id used by buy/dispatch, `label` the display name — clients compute all market
|
||||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
// previews (buy price, sell payout) from these + the economy constants.
|
||||||
export interface CatalogEntry {
|
export interface CatalogEntry {
|
||||||
model: string;
|
model: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ describe('buildSeed', () => {
|
|||||||
lat: 50.0333,
|
lat: 50.0333,
|
||||||
lon: 8.5706,
|
lon: 8.5706,
|
||||||
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full health on first run
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,3 +339,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface SeedPlane {
|
|||||||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||||||
// column as JSON text on insert; defaults to an empty queue.
|
// column as JSON text on insert; defaults to an empty queue.
|
||||||
queue?: string[];
|
queue?: string[];
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
|
||||||
|
health: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedPackage {
|
export interface SeedPackage {
|
||||||
@@ -99,6 +101,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;
|
||||||
@@ -169,6 +181,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];
|
||||||
@@ -211,6 +244,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];
|
||||||
}
|
}
|
||||||
@@ -237,6 +283,7 @@ export function buildSeed(input: {
|
|||||||
lat: hub.lat,
|
lat: hub.lat,
|
||||||
lon: hub.lon,
|
lon: hub.lon,
|
||||||
queue: [], // no queued legs on first run (v0.25.0)
|
queue: [], // no queued legs on first run (v0.25.0)
|
||||||
|
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||||
};
|
};
|
||||||
|
|
||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
|
|||||||
+11
-2
@@ -11,7 +11,8 @@ import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/si
|
|||||||
export interface PlaneState {
|
export interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
model: string;
|
model: string;
|
||||||
status: 'available' | 'flying';
|
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
@@ -28,6 +29,12 @@ export interface PlaneState {
|
|||||||
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
||||||
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||||
queue: string[];
|
queue: string[];
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
||||||
|
// plane is broken and cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is
|
||||||
|
// also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time.
|
||||||
|
health: number;
|
||||||
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||||
|
repairUntilSimTime: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||||
@@ -68,7 +75,7 @@ export interface StateSnapshot {
|
|||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||||
// the client can compute buy/sell/refuel previews without a separate endpoint.
|
// the client can compute buy/sell previews without a separate endpoint.
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
fuelPricePerL: number;
|
fuelPricePerL: number;
|
||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
@@ -140,6 +147,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
lat: live?.lat ?? p.lat,
|
lat: live?.lat ?? p.lat,
|
||||||
lon: live?.lon ?? p.lon,
|
lon: live?.lon ?? p.lon,
|
||||||
queue: parseIcaoQueue(p.queue),
|
queue: parseIcaoQueue(p.queue),
|
||||||
|
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||||
|
repairUntilSimTime: p.repairUntilSimTime,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
packages: packageRows.map((p) => ({
|
packages: packageRows.map((p) => ({
|
||||||
|
|||||||
+56
-21
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
|
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
snapshot({
|
||||||
catalog: [],
|
catalog: [],
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
|
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -140,11 +140,37 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only)', () => {
|
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => {
|
||||||
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
|
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(1);
|
||||||
|
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||||||
|
const coords = fc.features[0].geometry.coordinates;
|
||||||
|
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // one arc, exact endpoints
|
||||||
|
expect(coords[0]).toEqual([8.56, 50.03]); // starts at the plane's airport (EDDF)
|
||||||
|
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // ends at the queued airport (EHAM)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -154,7 +180,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a flying plane with an empty queue', () => {
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -165,7 +191,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -182,24 +208,27 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
|
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1', 'p3']); // p2 has an empty queue
|
||||||
|
const p3 = fc.features[1].geometry.coordinates;
|
||||||
|
expect(p3[0]).toEqual([9.68, 52.46]); // grounded chain starts at the plane's airport (EDDV)
|
||||||
|
expect(p3[p3.length - 1]).toEqual([-3.56, 40.47]); // …and ends at the queued airport (LEMD)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,6 +283,12 @@ describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the
|
|||||||
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
||||||
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ISS-077 (v0.41.0): a flying carrier's trace starts at its landing airport — when that IS the
|
||||||
|
// package's destination there is nothing to draw (the solid active-leg layer covers it).
|
||||||
|
it('returns an empty collection when the start point equals the dest airport', () => {
|
||||||
|
expect(hoverTraceGeoJson(AIRPORTS.get('EHAM')!, 'EHAM', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||||
|
|||||||
+20
-9
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
|
|||||||
export interface PlaneFeatureProps {
|
export interface PlaneFeatureProps {
|
||||||
id: string;
|
id: string;
|
||||||
modelLabel: string;
|
modelLabel: string;
|
||||||
status: 'available' | 'flying';
|
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
fuelText: string; // "1234 / 6300 L"
|
fuelText: string; // "1234 / 6300 L"
|
||||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||||
flying: boolean;
|
flying: boolean;
|
||||||
@@ -115,7 +116,10 @@ export function hoverRouteGeoJson(
|
|||||||
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
||||||
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
||||||
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
||||||
// collection when the destination is missing from the dataset — never guessed.
|
// collection when the destination is missing from the dataset — never guessed. ISS-077 (v0.41.0):
|
||||||
|
// also empty when the start point IS the destination airport itself (a flying carrier's trace now
|
||||||
|
// starts at its landing airport, which can equal the package's dest) — nothing to draw there; the
|
||||||
|
// solid active-leg layer covers that case.
|
||||||
export function hoverTraceGeoJson(
|
export function hoverTraceGeoJson(
|
||||||
from: AirportCoord,
|
from: AirportCoord,
|
||||||
toIcao: string,
|
toIcao: string,
|
||||||
@@ -123,6 +127,7 @@ export function hoverTraceGeoJson(
|
|||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const to = airportsByIcao.get(toIcao);
|
const to = airportsByIcao.get(toIcao);
|
||||||
if (!to) return EMPTY_LINES;
|
if (!to) return EMPTY_LINES;
|
||||||
|
if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: [
|
features: [
|
||||||
@@ -165,19 +170,25 @@ export function routesGeoJson(
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
|
// ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue,
|
||||||
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
|
// chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination
|
||||||
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
|
// (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer),
|
||||||
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
|
// and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current
|
||||||
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
|
// airport (locationIcao → queue[0] → …). 'repairing' planes draw nothing — they can't fly. Arcs are
|
||||||
|
// concatenated with the shared joint point dropped once. A plane is skipped — never guessed — when
|
||||||
|
// its first point or any queued ICAO is missing from the airport index; empty collection when
|
||||||
|
// nothing qualifies.
|
||||||
export function queuedRoutesGeoJson(
|
export function queuedRoutesGeoJson(
|
||||||
planes: PlaneState[],
|
planes: PlaneState[],
|
||||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
const features: MapLineFeature[] = [];
|
const features: MapLineFeature[] = [];
|
||||||
for (const p of planes) {
|
for (const p of planes) {
|
||||||
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
|
if (p.queue.length === 0) continue;
|
||||||
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
// First point of the chain: a flying plane's landing airport, a grounded one's current airport.
|
||||||
|
const firstIcao = p.status === 'flying' ? p.destIcao : p.status === 'available' ? p.locationIcao : null;
|
||||||
|
if (!firstIcao) continue;
|
||||||
|
const pts = [firstIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
||||||
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
||||||
const coords: [number, number][] = [];
|
const coords: [number, number][] = [];
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
|||||||
+8
-15
@@ -14,7 +14,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
@@ -115,6 +115,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'destIcao',
|
'destIcao',
|
||||||
'destName',
|
'destName',
|
||||||
'fuel',
|
'fuel',
|
||||||
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
'id',
|
'id',
|
||||||
'lat',
|
'lat',
|
||||||
'locationIcao',
|
'locationIcao',
|
||||||
@@ -123,6 +124,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -192,7 +194,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
]) {
|
]) {
|
||||||
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
||||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||||
@@ -207,10 +209,9 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||||
|
|
||||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
|
||||||
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
|
||||||
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
'#/components/schemas/LoadPackageRequest',
|
'#/components/schemas/LoadPackageRequest',
|
||||||
);
|
);
|
||||||
@@ -230,9 +231,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'#/components/schemas/SellPlaneRequest',
|
'#/components/schemas/SellPlaneRequest',
|
||||||
);
|
);
|
||||||
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
|
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
|
||||||
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
|
||||||
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
|
||||||
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
|
||||||
|
|
||||||
// Path parameters are declared on the parameterized routes.
|
// Path parameters are declared on the parameterized routes.
|
||||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||||
@@ -241,7 +240,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
||||||
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||||
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
|
||||||
|
|
||||||
// Request schema shapes.
|
// Request schema shapes.
|
||||||
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
||||||
@@ -263,11 +262,5 @@ describe('OpenAPI spec', () => {
|
|||||||
const sell = openApiSpec.components.schemas.SellPlaneRequest;
|
const sell = openApiSpec.components.schemas.SellPlaneRequest;
|
||||||
expect(sell.required).toEqual(['planeId']);
|
expect(sell.required).toEqual(['planeId']);
|
||||||
expect(sell.properties.planeId.type).toBe('string');
|
expect(sell.properties.planeId.type).toBe('string');
|
||||||
|
|
||||||
// RefuelRequest (v0.16.0): both fields optional — no body / {} means "full".
|
|
||||||
const refuel = openApiSpec.components.schemas.RefuelRequest;
|
|
||||||
expect((refuel as { required?: string[] }).required ?? []).toEqual([]);
|
|
||||||
expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']);
|
|
||||||
expect(refuel.properties.packageId.type).toBe('string');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+29
-46
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'Flight Ops API',
|
title: 'Flight Ops API',
|
||||||
description:
|
description:
|
||||||
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -17,13 +17,13 @@ export const openApiSpec = {
|
|||||||
paths: {
|
paths: {
|
||||||
'/api/airports': {
|
'/api/airports': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'List EU airports',
|
summary: 'List world airports',
|
||||||
description:
|
description:
|
||||||
'All fixed-wing EU airports (large, medium and seaplane bases) as a GeoJSON FeatureCollection.',
|
'The curated world airport dataset — one marker per metro area (large airports only, close clusters merged to the biggest; see scripts/build-world-airports.mjs) — as a GeoJSON FeatureCollection.',
|
||||||
operationId: 'listAirports',
|
operationId: 'listAirports',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'GeoJSON FeatureCollection of EU airports',
|
description: 'GeoJSON FeatureCollection of world airports',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/AirportFeatureCollection' },
|
schema: { $ref: '#/components/schemas/AirportFeatureCollection' },
|
||||||
@@ -37,7 +37,7 @@ export const openApiSpec = {
|
|||||||
get: {
|
get: {
|
||||||
summary: 'Game state snapshot',
|
summary: 'Game state snapshot',
|
||||||
description:
|
description:
|
||||||
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
||||||
operationId: 'getState',
|
operationId: 'getState',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -194,7 +194,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Fly a plane to an airport',
|
summary: 'Fly a plane to an airport',
|
||||||
description:
|
description:
|
||||||
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there.',
|
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there. ISS-082 (v0.42.0): dispatch auto-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required)) at fuelPricePerL per liter minus a reputation-scaled discount, deducted from cash; no separate refuel endpoint exists anymore.',
|
||||||
operationId: 'flyPlane',
|
operationId: 'flyPlane',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -223,7 +223,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description:
|
description:
|
||||||
'Plane not grounded, or insufficient fuel for the leg (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty)',
|
'Plane not grounded, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,7 +233,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Queue a flight leg for a plane',
|
summary: 'Queue a flight leg for a plane',
|
||||||
description:
|
description:
|
||||||
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).',
|
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later dispatch.',
|
||||||
operationId: 'queueLeg',
|
operationId: 'queueLeg',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -366,39 +366,26 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/planes/{planeId}/refuel': {
|
'/api/planes/{planeId}/repair': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Refuel a grounded plane',
|
summary: 'Repair a grounded plane',
|
||||||
description:
|
description:
|
||||||
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0); the per-liter price is reduced by a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation refuels cheaper; no discount at the starting reputation). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
|
'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
|
||||||
operationId: 'refuelPlane',
|
operationId: 'repairPlane',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
],
|
],
|
||||||
requestBody: {
|
|
||||||
required: false,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/RefuelRequest' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Refueled — full state snapshot (fuel raised by exactly the added liters, cash reduced by the cost)',
|
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
400: {
|
|
||||||
description: 'Invalid body (not an object, unknown field, unknown mode, or forFlight without packageId)',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
|
||||||
},
|
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id (or unknown package id in forFlight mode)',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description:
|
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||||
'Plane is flying, the package is not loaded on this plane (or is expired), the leg does not fit a full tank, or cash is insufficient for the refuel',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -476,7 +463,7 @@ export const openApiSpec = {
|
|||||||
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
|
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
|
||||||
items: { $ref: '#/components/schemas/CatalogEntry' },
|
items: { $ref: '#/components/schemas/CatalogEntry' },
|
||||||
},
|
},
|
||||||
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this, reduced by a reputation-scaled discount at refuel time (v0.38.0).' },
|
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
||||||
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
||||||
},
|
},
|
||||||
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
||||||
@@ -567,22 +554,6 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['planeId'],
|
required: ['planeId'],
|
||||||
},
|
},
|
||||||
RefuelRequest: {
|
|
||||||
type: 'object',
|
|
||||||
description:
|
|
||||||
'Optional refuel body (v0.16.0) — omit it entirely or send {} to fill the tank; both fields are optional.',
|
|
||||||
properties: {
|
|
||||||
mode: {
|
|
||||||
enum: ['full', 'forFlight'],
|
|
||||||
description:
|
|
||||||
'"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
|
|
||||||
},
|
|
||||||
packageId: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Required with mode "forFlight": a package LOADED on this plane — its destIcao is the refuel target.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Error: {
|
Error: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -595,7 +566,7 @@ export const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
model: { type: 'string' },
|
model: { type: 'string' },
|
||||||
status: { enum: ['available', 'flying'] },
|
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
|
||||||
fuel: { type: 'number' },
|
fuel: { type: 'number' },
|
||||||
maxFuel: { type: 'number' },
|
maxFuel: { type: 'number' },
|
||||||
locationIcao: { type: 'string' },
|
locationIcao: { type: 'string' },
|
||||||
@@ -620,6 +591,16 @@ export const openApiSpec = {
|
|||||||
description:
|
description:
|
||||||
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
|
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
|
||||||
},
|
},
|
||||||
|
health: {
|
||||||
|
type: 'number',
|
||||||
|
description:
|
||||||
|
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
|
||||||
|
},
|
||||||
|
repairUntilSimTime: {
|
||||||
|
type: ['integer', 'null'],
|
||||||
|
description:
|
||||||
|
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -635,6 +616,8 @@ export const openApiSpec = {
|
|||||||
'lat',
|
'lat',
|
||||||
'lon',
|
'lon',
|
||||||
'queue',
|
'queue',
|
||||||
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PackageState: {
|
PackageState: {
|
||||||
|
|||||||
+192
-9
@@ -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 { PLANE_TYPES } 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';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
clampTimeScale,
|
clampTimeScale,
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expirePackages,
|
||||||
|
finishRepairs,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
MAX_TIME_SCALE,
|
MAX_TIME_SCALE,
|
||||||
MIN_TIME_SCALE,
|
MIN_TIME_SCALE,
|
||||||
@@ -297,19 +298,64 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
|
it('auto-refuels a short tank and chains the next queued leg when cash allows (ISS-082)', async () => {
|
||||||
const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
|
const { inst, from, to, km, type } = await setupFlight('chain-autorefuel.db');
|
||||||
|
|
||||||
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
|
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty; the LEMD→EHAM hop needs a top-up.
|
||||||
const w = weatherFor(FROM);
|
const w = weatherFor(FROM);
|
||||||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({
|
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
|
||||||
fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
|
|
||||||
queue: JSON.stringify(['EHAM']),
|
|
||||||
})
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable
|
||||||
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
|
||||||
|
// requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
|
||||||
|
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
|
||||||
|
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
|
||||||
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
|
||||||
|
const nextRequiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * fNext,
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
|
||||||
|
);
|
||||||
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||||
|
const liters = Math.max(0, target - landedFuel);
|
||||||
|
expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left
|
||||||
|
const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying'); // chained — the top-up funded it…
|
||||||
|
expect(plane.destIcao).toBe('EHAM');
|
||||||
|
expect(plane.departureSimTime).toBe(clock);
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order
|
||||||
|
expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level
|
||||||
|
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db');
|
||||||
|
|
||||||
|
// Same empty-tank landing as above, but the account is broke — the top-up is unaffordable.
|
||||||
|
const w = weatherFor(FROM);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await inst.db.update(schema.account).set({ cash: 0 });
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
@@ -317,7 +363,10 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(plane.status).toBe('available'); // grounded safely…
|
expect(plane.status).toBe('available'); // grounded safely…
|
||||||
expect(plane.destIcao).toBeNull();
|
expect(plane.destIcao).toBeNull();
|
||||||
expect(plane.departureSimTime).toBeNull();
|
expect(plane.departureSimTime).toBeNull();
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for refuel + re-dispatch
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch
|
||||||
|
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched
|
||||||
|
expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grounds as before when the queue is empty', async () => {
|
it('grounds as before when the queue is empty', async () => {
|
||||||
@@ -331,6 +380,140 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||||
|
const FROM = 'EDDF';
|
||||||
|
const TO = 'LEMD';
|
||||||
|
|
||||||
|
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
|
||||||
|
async function setupFlight(name: string) {
|
||||||
|
const inst = await freshDb(name);
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
return { inst, from, to, km, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||||
|
const { inst, km } = await setupFlight('wear-leg.db');
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at 0 across multiple legs', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
|
||||||
|
// Start below one leg's wear so the first landing clamps…
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // clamped — never negative…
|
||||||
|
|
||||||
|
// …and a second leg keeps it pinned at 0.
|
||||||
|
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
|
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
|
||||||
|
const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
|
||||||
|
// Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
|
||||||
|
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
|
||||||
|
// guard can explain why it doesn't chain.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // broken by this landing's wear…
|
||||||
|
expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||||
|
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
|
||||||
|
const inst = await freshDb('repair-finish.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||||
|
const inst = await freshDb('repair-tick.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
||||||
|
await tickOnce(inst);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
|
||||||
|
const inst = await freshDb('repair-curve.db');
|
||||||
|
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
|
||||||
|
const gate = T0 + REPAIR_DURATION_MS;
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
|
||||||
|
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
expect(plane.health).toBe(50);
|
||||||
|
|
||||||
|
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
|
||||||
|
const inst2 = await freshDb('repair-curve-floor.db');
|
||||||
|
await inst2.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
|
||||||
|
const plane2 = (await inst2.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
|
||||||
|
|
||||||
|
// At the gate: exactly 100 and back in service.
|
||||||
|
expect(await finishRepairs(inst, gate)).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.health).toBe(100);
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||||
const FROM = 'EDDF';
|
const FROM = 'EDDF';
|
||||||
const TO = 'LEMD';
|
const TO = 'LEMD';
|
||||||
|
|||||||
+84
-11
@@ -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 { PLANE_TYPES } 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';
|
||||||
@@ -79,6 +80,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
await expirePackages(inst, clockMs);
|
await expirePackages(inst, clockMs);
|
||||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||||
landedThisTick = await deliverPackages(inst, clockMs);
|
landedThisTick = await deliverPackages(inst, clockMs);
|
||||||
|
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||||
|
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||||
|
await finishRepairs(inst, clockMs);
|
||||||
await spawnOpenPackages(inst, clockMs);
|
await spawnOpenPackages(inst, clockMs);
|
||||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||||
// at its new location within the same tick.
|
// at its new location within the same tick.
|
||||||
@@ -126,14 +130,48 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
|
||||||
|
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
|
||||||
|
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
|
||||||
|
// back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as
|
||||||
|
// expirePackages: one guarded pass over the rows. Returns the count flipped.
|
||||||
|
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||||||
|
let n = 0;
|
||||||
|
for (const p of rows) {
|
||||||
|
if (p.repairUntilSimTime === null) continue; // defensive — repairing always carries a gate
|
||||||
|
// ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a
|
||||||
|
// plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the
|
||||||
|
// start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive,
|
||||||
|
// as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane
|
||||||
|
// is still 'repairing' and grounded until the gate passes.
|
||||||
|
const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS));
|
||||||
|
if (p.repairUntilSimTime <= clockMs) {
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'available', health: 100, repairUntilSimTime: null })
|
||||||
|
.where(eq(schema.planes.id, p.id));
|
||||||
|
n++;
|
||||||
|
} else {
|
||||||
|
const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100)));
|
||||||
|
if (newHealth !== p.health) {
|
||||||
|
await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||||
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||||
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||||
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
// auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
|
||||||
// immediately from the arrival airport; otherwise ground the plane there with
|
// broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
|
||||||
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
// airport; with a short tank, ISS-082 (v0.42.0) tops
|
||||||
|
// up to 150% of the requirement (capped at tank capacity) when cash allows and
|
||||||
|
// chains anyway — only an unaffordable top-up grounds the plane there with its
|
||||||
|
// remaining fuel and the queue kept intact for a later dispatch.
|
||||||
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||||||
// leg whose own arrival is in the future, so it can never be awarded twice.
|
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||||||
@@ -170,6 +208,9 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
|
||||||
|
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
|
||||||
|
|
||||||
// Deliver every bound package whose destination is the airport just reached —
|
// Deliver every bound package whose destination is the airport just reached —
|
||||||
// runs whether or not a queued leg chains next (ISS-043).
|
// runs whether or not a queued leg chains next (ISS-043).
|
||||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||||
@@ -194,12 +235,16 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
|
|
||||||
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
||||||
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
||||||
// (requiredFuelFor is independent of current load). Otherwise ground here; an
|
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
||||||
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
|
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
||||||
// intact either way so a grounded plane can refuel and re-dispatch.
|
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
|
||||||
|
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0
|
||||||
|
// (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with
|
||||||
|
// the queue intact until repaired, exactly like flyPlane's broken guard.
|
||||||
const queue = parseIcaoQueue(p.queue);
|
const queue = parseIcaoQueue(p.queue);
|
||||||
let chainedTo: string | null = null;
|
let chainedTo: string | null = null;
|
||||||
if (queue.length > 0) {
|
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||||
|
if (queue.length > 0 && newHealth > 0) {
|
||||||
const nextDest = queue[0];
|
const nextDest = queue[0];
|
||||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -214,6 +259,20 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
if (nextRequiredL <= landedFuel) {
|
if (nextRequiredL <= landedFuel) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
chainedTo = nextDest;
|
chainedTo = nextDest;
|
||||||
|
} else {
|
||||||
|
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
|
||||||
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||||
|
const liters = Math.max(0, target - landedFuel);
|
||||||
|
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
|
||||||
|
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!fresh) throw new Error('account row missing');
|
||||||
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
|
if (cost === 0 || fresh.cash >= cost) {
|
||||||
|
queue.shift();
|
||||||
|
chainedTo = nextDest;
|
||||||
|
topUpFuel = target;
|
||||||
|
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +284,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
locationIcao: to.icao,
|
locationIcao: to.icao,
|
||||||
lat: to.lat,
|
lat: to.lat,
|
||||||
lon: to.lon,
|
lon: to.lon,
|
||||||
fuel: landedFuel,
|
fuel: topUpFuel ?? landedFuel, // ISS-082: the post-top-up level when a top-up funded the chain
|
||||||
|
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||||||
destIcao: chainedTo,
|
destIcao: chainedTo,
|
||||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||||
queue: JSON.stringify(queue),
|
queue: JSON.stringify(queue),
|
||||||
@@ -236,6 +296,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
lat: to.lat,
|
lat: to.lat,
|
||||||
lon: to.lon,
|
lon: to.lon,
|
||||||
fuel: landedFuel,
|
fuel: landedFuel,
|
||||||
|
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
destIcao: null,
|
destIcao: null,
|
||||||
queue: JSON.stringify(queue),
|
queue: JSON.stringify(queue),
|
||||||
@@ -324,9 +385,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.38.0",
|
"version": "0.45.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build data/airports_world.csv from the full OurAirports world dump (ISS-079, v0.44.0).
|
||||||
|
//
|
||||||
|
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
|
||||||
|
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
|
||||||
|
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
|
||||||
|
//
|
||||||
|
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
|
||||||
|
// Aborts without writing anything if the download fails after retries — no fabricated data.
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
|
||||||
|
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
|
||||||
|
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
|
||||||
|
|
||||||
|
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
|
||||||
|
function parseCsv(text) {
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let field = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c === '"') {
|
||||||
|
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
|
||||||
|
} else field += c;
|
||||||
|
} else if (c === '"') inQuotes = true;
|
||||||
|
else if (c === ',') { row.push(field); field = ''; }
|
||||||
|
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
||||||
|
else if (c !== '\r') field += c;
|
||||||
|
}
|
||||||
|
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
|
||||||
|
const [header, ...rest] = rows;
|
||||||
|
return rest
|
||||||
|
.filter((r) => r.length === header.length && !(r.length === 1 && r[0] === ''))
|
||||||
|
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineKm(a, b) {
|
||||||
|
const toRad = (d) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(b.latitude_deg - a.latitude_deg);
|
||||||
|
const dLon = toRad(b.longitude_deg - a.longitude_deg);
|
||||||
|
const x =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(a.latitude_deg)) * Math.cos(toRad(b.latitude_deg)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Biggest" proxy — the CSV carries no passenger/traffic data, so rank by service signals:
|
||||||
|
// scheduled_service first, then an IATA code (commercially served), then a Wikipedia page,
|
||||||
|
// then file order. ponytail: good-enough proxy for hub-vs-satellite; swap in pax data if it ever exists.
|
||||||
|
function biggestOf(members) {
|
||||||
|
const rank = (r) => [r.scheduled_service === 'yes' ? 1 : 0, r.iata_code ? 1 : 0, r.wikipedia_link ? 1 : 0];
|
||||||
|
let best = members[0];
|
||||||
|
for (const m of members.slice(1)) {
|
||||||
|
const rb = rank(m);
|
||||||
|
const rk = rank(best);
|
||||||
|
if (rb[0] > rk[0] || (rb[0] === rk[0] && rb[1] > rk[1]) || (rb[0] === rk[0] && rb[1] === rk[1] && rb[2] > rk[2])) best = m;
|
||||||
|
}
|
||||||
|
return best; // ties keep the lowest file order (members are in file order)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(url, attempts = 3) {
|
||||||
|
for (let i = 1; i <= attempts; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!(text.startsWith('"id"') || text.startsWith('id,')) || text.split('\n').length < 10_000) throw new Error('response does not look like the OurAirports CSV');
|
||||||
|
return text;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`download attempt ${i}/${attempts} failed: ${err.message}`);
|
||||||
|
if (i === attempts) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await download(SOURCE_URL);
|
||||||
|
const [headerLine] = text.split('\n');
|
||||||
|
const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"|"$/g, ''));
|
||||||
|
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
|
||||||
|
|
||||||
|
const allRows = parseCsv(text);
|
||||||
|
const large = allRows.filter((r) => r.type === 'large_airport');
|
||||||
|
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
|
||||||
|
|
||||||
|
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
|
||||||
|
// radius, otherwise it starts a new one. Deterministic — same input, same output.
|
||||||
|
const clusters = []; // { anchorIdx (into `large`), members }
|
||||||
|
for (let i = 0; i < large.length; i++) {
|
||||||
|
let joined = false;
|
||||||
|
for (const c of clusters) {
|
||||||
|
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
||||||
|
}
|
||||||
|
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = [];
|
||||||
|
const multiClusters = [];
|
||||||
|
for (const c of clusters) {
|
||||||
|
const winner = biggestOf(c.members.map((i) => large[i]));
|
||||||
|
kept.push(winner);
|
||||||
|
if (c.members.length > 1) {
|
||||||
|
multiClusters.push({
|
||||||
|
city: winner.municipality || winner.name,
|
||||||
|
keptIcao: winner.icao_code || winner.ident,
|
||||||
|
dropped: c.members.map((i) => large[i]).filter((r) => r !== winner).map((r) => `${r.icao_code || r.ident} (${r.name})`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guards (fail loud before writing anything) ---
|
||||||
|
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
|
||||||
|
const keys = new Set(kept.map(key));
|
||||||
|
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
||||||
|
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
|
||||||
|
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
|
||||||
|
}
|
||||||
|
if (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); process.exit(1); }
|
||||||
|
|
||||||
|
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
||||||
|
|
||||||
|
console.log(`wrote ${OUT_FILE}: ${kept.length} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
|
||||||
|
console.log('sample merged cities (kept ← dropped):');
|
||||||
|
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);
|
||||||
Reference in New Issue
Block a user