Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5093fba82e | ||
|
|
cde4334946 | ||
|
|
127d8f97f0 | ||
|
|
479c5448f7 | ||
|
|
d2b799e572 | ||
|
|
664906b8ce | ||
|
|
b60006a949 | ||
|
|
fe84d9a0bb | ||
|
|
43c4bb97a9 | ||
|
|
b04e504263 | ||
|
|
d87d848ca2 | ||
|
|
eae680c002 | ||
|
|
c3fa5bf613 | ||
|
|
fe60bd0482 | ||
|
|
db77f03ce1 | ||
|
|
d00a012c4b | ||
|
|
306f81aec6 | ||
|
|
ec26f9f366 | ||
|
|
ba2c36a3f8 | ||
|
|
81ba642ec5 | ||
|
|
b0f1bd8b67 | ||
|
|
21dd37f88c | ||
|
|
b2034ebc52 | ||
|
|
837aa60a7d | ||
|
|
7b6a942e0c | ||
|
|
1a1834abc7 | ||
|
|
b5bb0d01ce | ||
|
|
fe23c9ff0f | ||
|
|
e24a5788c0 | ||
|
|
b89d351587 | ||
|
|
034d7a8d94 | ||
|
|
0aca4d63aa | ||
|
|
fb369a1afb | ||
|
|
78961fdb36 | ||
|
|
d2defe4cd3 | ||
|
|
7d117f6809 | ||
|
|
49dfd2ca6e | ||
|
|
65cbcf6e17 | ||
|
|
a56937cee2 | ||
|
|
4b01c320fa | ||
|
|
d278c8ba9c | ||
|
|
21cfe16d5d | ||
|
|
70a726d546 | ||
|
|
199704f761 | ||
|
|
12d900b22a | ||
|
|
9c2c64fe89 | ||
|
|
b2ff1d631b | ||
|
|
d7f352adcf | ||
|
|
2b5c8541d7 | ||
|
|
673365f49c | ||
|
|
bb4e3b96f3 | ||
|
|
e970f95036 | ||
|
|
03731b938e | ||
|
|
1d9e9ade58 | ||
|
|
6a819b8d5a | ||
|
|
c5413aff65 | ||
|
|
44996b434f | ||
|
|
d0d764e0c1 | ||
|
|
d0aec35db1 | ||
|
|
7671c028b4 | ||
|
|
baa4b0fa7c | ||
|
|
c06e038130 | ||
|
|
18e5c832e3 | ||
|
|
1983d17099 | ||
|
|
9b2938a5b0 | ||
|
|
0a40143497 | ||
|
|
5268a39fe9 | ||
|
|
ae91bf6a5d | ||
|
|
a8c4e93dc1 | ||
|
|
e5693b3301 | ||
|
|
dae354410f | ||
|
|
d53ad41c09 | ||
|
|
82061ed869 | ||
|
|
2a4b341a9c | ||
|
|
cd2c766895 | ||
|
|
23896b2cc4 | ||
|
|
189a2c04eb | ||
|
|
a0560ec767 | ||
|
|
d31bc5d60f | ||
|
|
8319b2fa78 | ||
|
|
f01c3f417c | ||
|
|
c3339f0e40 | ||
|
|
d215203988 | ||
|
|
11fc6f9d5c | ||
|
|
8f32d5bf81 | ||
|
|
a8233829c3 | ||
|
|
bf6f1b3c95 | ||
|
|
9ce8660fd2 | ||
|
|
3dbac9c549 | ||
|
|
b76acbacac | ||
|
|
479b993c09 | ||
|
|
76f5b6f8ce | ||
|
|
1401a2f6eb | ||
|
|
0de0aaffd9 | ||
|
|
3ac7075eb0 | ||
|
|
320bdf3b72 | ||
|
|
dd7159c109 | ||
|
|
78032a2051 | ||
|
|
64da8e67bf | ||
|
|
b2d03846f5 | ||
|
|
68cfad434b | ||
|
|
5d92b5edaa | ||
|
|
4a6a8592e0 | ||
|
|
b8f321dfd5 | ||
|
|
336ed4ee7c | ||
|
|
929452ca33 | ||
|
|
8daca54840 | ||
|
|
0f5f3ae23e | ||
|
|
8ec485b93e | ||
|
|
ea740fc378 | ||
|
|
718e3a0177 | ||
|
|
5030fcd8b4 | ||
|
|
6d8e1ad35e | ||
|
|
0ac133f750 | ||
|
|
d3038b3c7f | ||
|
|
847e74b7b8 | ||
|
|
394d51d827 | ||
|
|
264778522c | ||
|
|
e55078fb5d | ||
|
|
4e585cbed1 | ||
|
|
89ad09a04d | ||
|
|
bf2c46b959 | ||
|
|
fa64e32f65 | ||
|
|
04b6f20a93 | ||
|
|
acee806180 | ||
|
|
1e3992f872 | ||
|
|
57cc6705f2 | ||
|
|
c9e9113a45 | ||
|
|
5e7ec903d3 | ||
|
|
0b946b743c | ||
|
|
86d486fb69 | ||
|
|
b4b25551ff | ||
|
|
8a12f07703 |
+58
-2
@@ -189,10 +189,56 @@ jobs:
|
|||||||
# in-network app host out of any proxy path.
|
# in-network app host out of any proxy path.
|
||||||
NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl
|
NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl
|
||||||
no_proxy: app,localhost,127.0.0.1,.kkhost.pl
|
no_proxy: app,localhost,127.0.0.1,.kkhost.pl
|
||||||
|
# Machine-readable e2e results for the job-summary step below (Playwright's built-in
|
||||||
|
# json reporter; without this env var it would dump to stdout). test-results/ is git-ignored.
|
||||||
|
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/e2e.json
|
||||||
# Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
|
# Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
|
||||||
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
|
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
|
||||||
# visibility in the job log; failure error context/snapshots are unchanged.
|
# visibility in the job log; failure error context/snapshots are unchanged. The json
|
||||||
run: npx playwright test --reporter=list
|
# reporter adds a machine-readable report (PLAYWRIGHT_JSON_OUTPUT_FILE above) — no
|
||||||
|
# execution behavior changes, reporters only observe.
|
||||||
|
run: npx playwright test --reporter=list,json
|
||||||
|
|
||||||
|
# Appends e2e results to the job-summary panel — mirrors how unit tests get there
|
||||||
|
# (vitest's built-in github-actions reporter auto-appends $GITHUB_STEP_SUMMARY in CI).
|
||||||
|
# if: always() so a FAILED suite still gets summarized; non-fatal by design — a missing
|
||||||
|
# or malformed report must never mask the real e2e exit code. The script is single-quoted
|
||||||
|
# for bash, so it contains no apostrophes.
|
||||||
|
- name: E2E job summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = JSON.parse(fs.readFileSync("test-results/e2e.json", "utf8"));
|
||||||
|
} catch {
|
||||||
|
process.exit(0); // e2e never ran (earlier step failed) or no report — nothing to summarize
|
||||||
|
}
|
||||||
|
let passed = 0, skipped = 0;
|
||||||
|
const failed = [];
|
||||||
|
const walk = (suite) => {
|
||||||
|
for (const spec of suite.specs ?? []) {
|
||||||
|
if (!spec.ok) failed.push(`${suite.title} › ${spec.title}`);
|
||||||
|
else if ((spec.tests ?? []).length && spec.tests.every((t) => t.expectedStatus === "skipped")) skipped++;
|
||||||
|
else passed++;
|
||||||
|
}
|
||||||
|
for (const sub of suite.suites ?? []) walk(sub);
|
||||||
|
};
|
||||||
|
for (const s of report.suites ?? []) walk(s);
|
||||||
|
const lines = [
|
||||||
|
"", "## E2E results", "",
|
||||||
|
`- Passed: ${passed}`,
|
||||||
|
`- Failed: ${failed.length}`,
|
||||||
|
`- Skipped: ${skipped}`,
|
||||||
|
];
|
||||||
|
if (failed.length) {
|
||||||
|
lines.push("", "### Failed specs");
|
||||||
|
for (const f of failed) lines.push(`- \`${f}\``);
|
||||||
|
}
|
||||||
|
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
||||||
|
if (summaryFile) fs.appendFileSync(summaryFile, lines.join("\n") + "\n");
|
||||||
|
' || true
|
||||||
|
|
||||||
# The daemon is shared with production — never leave our stack or network behind.
|
# The daemon is shared with production — never leave our stack or network behind.
|
||||||
# -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
|
# -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
|
||||||
@@ -201,3 +247,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.ci.yml -p flight-ci down --remove-orphans -v || true
|
docker compose -f docker-compose.ci.yml -p flight-ci down --remove-orphans -v || true
|
||||||
docker network remove flight-ci-e2e 2>/dev/null || true
|
docker network remove flight-ci-e2e 2>/dev/null || true
|
||||||
|
|
||||||
|
# The shared runner dind still accumulates between runs what the steps above can't reach:
|
||||||
|
# dangling images/layers from the per-run flight-ops-ci rebuilds, BuildKit cache, and job
|
||||||
|
# containers leaked by interrupted runs. Prune them here (after stack + network cleanup).
|
||||||
|
# Deliberately NO --volumes: the daemon is shared with production and named volumes may hold
|
||||||
|
# other workflows' persistent data — this project's own flight-ci_flight-db volume is already
|
||||||
|
# dropped by the steps above. Non-fatal so a prune failure can never mask or break the run.
|
||||||
|
- name: Prune runner dind leftovers
|
||||||
|
if: always()
|
||||||
|
run: docker system prune -af || echo "::warning::dind prune failed — see logs"
|
||||||
|
|||||||
+299
@@ -1,5 +1,304 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.10.0 — 2026-09-27 22:31
|
||||||
|
- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn).
|
||||||
|
- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button.
|
||||||
|
- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion).
|
||||||
|
|
||||||
|
## v1.9.0 — 2026-09-27 20:57
|
||||||
|
- **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is.
|
||||||
|
|
||||||
|
## v1.8.0 — 2026-09-27 20:13
|
||||||
|
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
|
||||||
|
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
|
||||||
|
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
|
||||||
|
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
|
||||||
|
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
|
||||||
|
|
||||||
|
## v1.7.0 — 2026-09-26 20:24
|
||||||
|
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
|
||||||
|
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
|
||||||
|
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
|
||||||
|
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
|
||||||
|
|
||||||
|
## v1.6.0 — 2026-09-26 19:35
|
||||||
|
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
|
||||||
|
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
|
||||||
|
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
|
||||||
|
|
||||||
|
## v1.5.0 — 2026-09-26 19:06
|
||||||
|
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
|
||||||
|
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
|
||||||
|
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
|
||||||
|
|
||||||
|
## v1.4.0 — 2026-09-26 18:35
|
||||||
|
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
|
||||||
|
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
|
||||||
|
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
|
||||||
|
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
|
||||||
|
|
||||||
|
## v1.3.0 — 2026-09-26 17:05
|
||||||
|
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
|
||||||
|
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
|
||||||
|
- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths.
|
||||||
|
|
||||||
|
## v1.2.0 — 2026-09-26 16:25
|
||||||
|
- **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had.
|
||||||
|
- **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane.
|
||||||
|
- **Mint hook default → kamilkrzywda.** The e2e specs play with the seeded world's planes by id (`plane-starter`), so the no-body mint now resolves to the existing local player that owns them after backfill — creating NO new rows; a custom username still creates a fresh account, now WITH onboarding. `e2e/auth.ts` and every spec are untouched: they keep working because the session they mint is the owner's.
|
||||||
|
- **OpenAPI.** The seven plane-targeted action endpoints document the new 403 (buy-plane has no plane target, so it gains none); path count unchanged at eighteen — `openapi.test.ts`'s per-endpoint error-code lists updated to match.
|
||||||
|
- **Tests.** Unit: new backfill migration test in `lib/db/client.test.ts` builds a pre-v1.2.0 file (two planes without owner_id) and verifies open() backfills both to `'local'`, does not re-seed, and is a no-op on second open (a post-migration owner change survives reopen); new ownership suite in `lib/game/actions.test.ts` — 403 for every action when the session player doesn't own the plane (both directions), per-owner last-plane guard against the world total, buyer stamped as owner with their own cash debited and local's untouched; register + mint route tests assert exactly one onboarding plane at EDDF owned by the new account. All 74 existing action call sites in `actions.test.ts` now pass `'local'`; no e2e spec changes needed (the minted session is the owner's).
|
||||||
|
|
||||||
|
## v1.1.0 — 2026-09-26 14:39
|
||||||
|
- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook.
|
||||||
|
- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip.
|
||||||
|
- **Sign-in screen replaces the shell until authenticated.** New `components/SignInScreen.tsx`: full-screen dark panel with two forms (sign in / create account) sharing one submit path; errors surface in a single `role=alert` line. `app/page.tsx` gates on it: stored session → `/api/me` check on mount (stale id cleared, network error falls through to sign-in), then the game shell mounts only when authenticated — so an unauthenticated browser never polls `/api/state`. New client module `lib/authClient.ts` owns the localStorage key (`flight.session`) and builds the `X-Flight-Session` header; every app fetch (state poll, SSE URL, action POSTs, fly/queue in Map) now carries it. Admin panel logout drops back to the sign-in screen via a new `onLogout` prop (page → Map → AdminPanel).
|
||||||
|
- **E2E session hook + spec updates.** New test-only `POST /api/test/mint-player-session` (FLIGHT_TEST_HOOKS=1, 404 elsewhere — like its `/api/test/*` siblings): creates the users+players rows for a username if missing and mints a session, idempotent across container restarts since the CI DB volume persists. `e2e/auth.ts` mints + applies it per test (context extraHTTPHeaders for spec-side requests, localStorage init script for the app's own fetches); every guarded-route spec authenticates in a beforeEach; events.spec uses the real client path (`?session=`); api.spec/openapi tests track the new eighteenth endpoint. Admin spec reworked: unauthenticated visitors see the sign-in screen (no shell), wrong credentials error there, and logout returns to it.
|
||||||
|
- **Tests.** Unit: new `lib/auth.test.ts` (hash round-trip; 401 on missing/unknown/expired session; admin→local player join; 500 on missing players row), register route tests (validation matrix, shared-id rows + starter economy + minted session, case-insensitive 409) and mint-hook tests (gate off → 404 even with CI's FLIGHT_TEST_HOOKS=1 env — stubbed explicitly, default-username creation, idempotent re-mint, bad usernames). All existing state/events/action route tests now mint a session for the seeded admin user in beforeAll. OpenAPI: `/api/register` documented (RegisterRequest schema; 200/400/409) — spec is now eighteen paths.
|
||||||
|
|
||||||
|
## v1.0.0 — 2026-09-24 14:20
|
||||||
|
- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before.
|
||||||
|
- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas.
|
||||||
|
- **Tests.** Unit: fresh DB now asserts the seeded local player row (`local`/`local`/`kamilkrzywda`, starting cash/reputation) AND that no `account` table exists; new dedicated migration test builds a minimal legacy file containing only an `account` table and verifies open() copies its row into the local player, drops `account`, and is a no-op on second open. All existing suites (actions, engine, client, buy/sell/abandon routes) now read/write through the helper; the admin rows CRUD test's PK-less-table example moves from `account` to `sim_state` (the only remaining PK-less table), and the admin tables-list expectation swaps `account` → `players`. No new e2e spec — behavior is byte-identical and CI's existing suite covers the UI paths; a fresh-stack boot exercises the new seed path.
|
||||||
|
|
||||||
|
## v0.73.0 — 2026-09-24 12:24
|
||||||
|
- **New-package toasts removed.** The client-side snapshot diff in `lib/deriveEvents.ts` no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged.
|
||||||
|
|
||||||
|
## v0.72.0 — 2026-09-24 11:44
|
||||||
|
- **Admin login fixed.** The seeded admin's scrypt digest in `lib/db/client.ts` was computed with different parameters than the verifier (`verifyPassword()` passes the salt as a plain string to `scryptSync(pw, saltHex, 32)`), so real logins failed with 401 "invalid credentials" since v0.71.0. The seed now carries a digest produced by exactly that call (the plaintext still exists nowhere in code, tests or comments). Because the seed is `INSERT … WHERE NOT EXISTS`, pre-existing databases keep their old row — so every `open()` also runs an idempotent rotation (`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`): a no-op once healed, it repairs legacy rows at boot and is durable on the next persist. Unit tests cover the fresh seed with the new digest plus the rotate-on-open path (legacy file heals to the current digest; second open is a no-op).
|
||||||
|
|
||||||
|
## v0.71.0 — 2026-09-24 09:18
|
||||||
|
- **Admin panel.** A new "Admin" button on the right of the top bar opens a reusable modal (`components/Modal.tsx` — fixed overlay at z-index 60, above the z-50 toast stack; centered `role="dialog"` panel; closes on Escape and backdrop click) hosting `components/AdminPanel.tsx`. No new URL routes: everything lives behind that one button + modal.
|
||||||
|
- **Users + sessions tables.** New `users` (id PK, unique username, scrypt `salt$hex` password_hash, admin flag, created_at) and `sessions` (UUID id, user_id, 7-day expires_at) tables in the raw DDL (`lib/db/client.ts`) and typed Drizzle entries (`lib/db/schema.ts`). An **idempotent seeded admin** is inserted on every `open()` — user id `admin-kamilkrzywda`, username `kamilkrzywda`, admin=1, hash-only (the plaintext exists nowhere in code, tests or comments) — guarded by `WHERE NOT EXISTS` so pre-existing production DBs gain exactly one row and never a duplicate. The users table is also the foundation for multiplayer auth (#105/#107).
|
||||||
|
- **Auth API.** `POST /api/login {username,password}` verifies with node:crypto `scryptSync` + `timingSafeEqual`; an unknown username still runs one full scrypt against a fixed dummy digest so timing stays uniform. Success mints a 7-day session (lazy cleanup of expired rows) and returns `{sessionId, user:{username,admin}}`. `POST /api/logout` deletes the session named by the new `X-Flight-Session` header (idempotent); `GET /api/me` resolves it to the current user or 401. The client stores the sessionId in localStorage (`flight.session`) and sends the header on every authenticated request.
|
||||||
|
- **Generic whole-database CRUD behind the admin flag.** `lib/auth.ts`'s `requireAdmin` guard (no/expired session → 401, non-admin → 403) runs before any input is touched on: `GET /api/admin/tables` (user tables, sqlite_% excluded) and `GET|PUT|POST|DELETE /api/admin/tables/{name}/rows` — rows carry their rowid as `__rowid` so the PK-less tables (account, sim_state) are editable like any other; PUT updates only changed cells, POST inserts, DELETE removes by rowid. SQL safety: table names quoted only after sqlite_master validation, column names validated against PRAGMA table_info, values ALWAYS bound as parameters — no user input is ever concatenated into SQL.
|
||||||
|
- **UI.** The Admin modal shows a login form (wrong credentials show an error line), a "not an admin" notice for authenticated non-admins, or the grid: table list → per-table editable rows (each cell an input; per-row Save/Delete, "+ Add row", Refresh) with logout in the header area. Deliberately plain — it's a dev tool surface, not a product feature.
|
||||||
|
- **Test hook.** `POST /api/test/admin-session` (FLIGHT_TEST_HOOKS=1 gate like its siblings) mints a session for the seeded admin server-side and returns `{sessionId}` — so the e2e suite can exercise the authenticated panel WITHOUT the real admin password ever appearing in test code.
|
||||||
|
- **OpenAPI.** Five new paths documented (`/api/login`, `/api/logout`, `/api/me`, `/api/admin/tables`, `/api/admin/tables/{name}/rows` with all four verbs) plus their request/response schemas; endpoint count twelve → seventeen everywhere it is asserted (unit + e2e).
|
||||||
|
- **Tests.** Unit: the idempotent admin seed (fresh DB, reopen, and a legacy pre-v0.71.0 file), login success/wrong-password/unknown-username/malformed bodies with an in-test scrypt hash for a self-created user (the real admin's plaintext is never referenced), logout idempotency, /api/me with missing/unknown/expired/live sessions, the guard ladder (401 no header, 401 expired, 403 non-admin) and the full CRUD validation matrix (rows incl. `__rowid` for account, PUT-verified-by-refetch, POST+DELETE round-trip, unknown table 404, unknown column/bad limit/missing rowid 400). E2E (`e2e/admin.spec.ts`, CI-only): open modal → login form → wrong creds error → hook-minted session via localStorage → table list incl. packages/users → edit a cell + Save verified by an independent re-fetch → add + delete a users row → logout back to the login form.
|
||||||
|
|
||||||
|
## v0.70.0 — 2026-09-23 22:07
|
||||||
|
- **Default map view is now the whole world.** The initial camera in `components/Map.tsx` moves from the Europe overview `[10,52]` z4 to null island `[0,0]` (renamed constant `WORLD_CENTER`) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. `MIN_ZOOM` is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; `MAX_ZOOM = 11` and the flat-2D view are unchanged.
|
||||||
|
- **E2E.** The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: `e2e/map.spec.ts` gains a shared `frameLfrs` helper (`jumpTo` z5 + wait until the marker renders), used by all three of its click tests; `e2e/map-interactions.spec.ts` and `e2e/relocation.spec.ts` frame LFRS at z5 before their popup clicks. The plane-marker test in `e2e/map-interactions.spec.ts` fits EDDF+LEMD into the viewport first — `queryRenderedFeatures` is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. `e2e/hover.spec.ts`: the antimeridian test's `setMinZoom(0)` workaround is deleted — minZoom is natively 0 now, so `fitToAirports` can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in `e2e/map.spec.ts` pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0).
|
||||||
|
|
||||||
|
## v0.69.0 — 2026-09-23 21:54
|
||||||
|
- **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
|
||||||
|
- **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway.
|
||||||
|
- **Tests.** Unit: the `ignorePackage` suite is deleted; terminal-status fixtures drop their redundant `'ignored'` rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to `'expired'` mirroring `expireOpenPackages`; the taken-slot cooldown ladder keeps two stamped slots, both `'loaded'`. E2E: the Ignore UI test is deleted; `sim.spec.ts`'s soft-lock regression drains via the existing `/api/test/expire-package` hook (loading can't drain a pool — the starter's payload fits only the lightest band) and `expiry.spec.ts` cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.
|
||||||
|
|
||||||
|
## v0.68.0 — 2026-09-23 20:32
|
||||||
|
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
|
||||||
|
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`.
|
||||||
|
|
||||||
|
## v0.67.0 — 2026-09-23 18:03
|
||||||
|
- **Prune runner dind leftovers after e2e.** The CI runner's full-dind daemon is shared with production and other workflows, so between runs it accumulates what the existing cleanup steps can't reach: dangling images/layers from the per-run `flight-ops-ci` rebuilds, BuildKit cache (persists by design), and job containers leaked by interrupted runs. A new non-fatal "Prune runner dind leftovers" step (`if: always()`, placed after the existing "Cleanup e2e stack + network") runs `docker system prune -af || echo "::warning::dind prune failed — see logs"` so a prune failure can never mask or break the run's real result. Deliberately NO `--volumes`: named volumes on the shared daemon may hold other workflows' persistent data — this project's own `flight-ci_flight-db` volume is already dropped by the existing cleanup steps. No app/TS changes; CI validates the workflow itself.
|
||||||
|
|
||||||
|
## v0.66.0 — 2026-09-23 17:06
|
||||||
|
- **E2E results in the Gitea job summary (#141).** The run page's "Job summaries" panel showed vitest results (vitest's built-in `github-actions` reporter auto-appends `$GITHUB_STEP_SUMMARY` when `GITHUB_ACTIONS=true`) but nothing about the Playwright suite, so a failed e2e run required digging into raw logs to see which specs broke. The CI E2E step now also runs Playwright's built-in `json` reporter (`--reporter=list,json`, output pinned to git-ignored `test-results/e2e.json` via `PLAYWRIGHT_JSON_OUTPUT_FILE` — no new dependencies, no config/TS changes), and a new non-fatal "E2E job summary" step (`if: always()`) parses that report with an inline node script and appends spec-level passed/failed/skipped counts plus the file + title of every failed spec to `$GITHUB_STEP_SUMMARY` — mirroring exactly how vitest gets there. The append is guarded (`|| true`, missing/malformed report = silent no-op) so it can never fail or mask the e2e step's real exit code; test behavior, timeouts, workers and retries are untouched.
|
||||||
|
|
||||||
|
## v0.65.0 — 2026-09-23 12:58
|
||||||
|
- **Per-airport job pools with real-time refresh.** The market was a global bag of jobs (top-up to `MARKET_TARGET_OPEN_PACKAGES = 10` + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' `locationIcao`, flying planes' `destIcao` and queued-leg ICAOs — queued legs count even while their plane is in the shop (the work will be waiting at its next stop); a fleet fully repairing with empty queues has no pool, and orphaned pools simply expire out. Each pool airport holds exactly `POOL_TARGET_PER_AIRPORT = 10` open jobs in a fixed weight composition (`POOL_BANDS`: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. `spawnPoolPackages` in `lib/sim/engine.ts` replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new `generatePoolPackage` helper in `lib/game/seed.ts`, so they are indistinguishable — `buildSeed` now seeds a full pool at the starter's home hub, and `PACKAGE_COUNT_MIN/MAX` + `MARKET_TARGET_OPEN_PACKAGES` are retired.
|
||||||
|
- **Open jobs live on the wall clock.** New `created_real_ms INTEGER NOT NULL DEFAULT 0` column (guarded ALTER backfilled with `Date.now()` for existing rows): an open job expires `JOB_LIFETIME_MS = 1 h` after creation regardless of time scale or fleet state — checked every tick OUTSIDE the advance gate, so a paused world still refreshes its board. Loaded jobs keep their sim-clock deadline logic unchanged (delivery check, expiry floor, abandon penalty all untouched). Taken slots (loaded OR ignored) stamp `slot_free_at_real_ms` and hold their pool slot for a refill cooldown — `REFILL_DELAY_MS = 15 min`, or `SMALL_REFILL_DELAY_MS = 1 min` for the <4 t band — so the board visibly refreshes instead of instantly refilling; expired/delivered/abandoned never block. ponytail: one shared per-airport cooldown (the max stamp) rather than a per-band table, add when per-band refill timing matters.
|
||||||
|
- **Visibility + UI.** `/api/state` and SSE now filter open packages to pool origins (`poolAirports` in `lib/game/state.ts`; all non-open statuses pass through unchanged), so the right pane only ever shows jobs you can take — no disabled Load buttons for foreign-origin work. `PackageState` gains `expiresRealMs` (open → `createdRealMs + JOB_LIFETIME_MS`, else null; OpenAPI updated) and the open-job row renders a wall-clock countdown via the existing pure `formatDeadlineRelative` formatter (no new timer — the ~1 s poll re-renders anyway); loaded jobs keep their sim-deadline display.
|
||||||
|
- **Test hooks.** `/api/test/expire-package` rewritten for the wall clock: `{packageId}` ages an OPEN job's `createdRealMs` past its lifetime (`HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000`) and runs a tick, so it comes back already expired (200; unknown/non-open ids 404); no body creates one already-aged hub job (201). CI env: `docker-compose.ci.yml` sets `FLIGHT_JOB_REFILL_MS=5000` + `FLIGHT_JOB_SMALL_REFILL_MS=2000` under the existing `FLIGHT_TEST_HOOKS=1` gate — the lifetime stays 1 h everywhere on purpose, so `state.spec.ts`'s mid-suite container restart can't race a whole board expiring.
|
||||||
|
- **Tests.** Unit: `lib/sim/engine.test.ts` rewritten around the new functions — `expireLoadedPackages (sim clock)` and `expireOpenPackages (v0.65.0 wall clock)` suites (both with inclusive-boundary edges), the frozen-top-up test now asserts exactly 10 open jobs all at EDDF, and a six-test `spawnPoolPackages` suite pins band composition, determinism under an injected RNG + wall clock, pool membership for flying/queued airports (departure airport excluded while its plane is airborne), the taken-slot cooldown ladder, and the full tickOnce pipeline. `lib/game/seed.test.ts`: buildSeed now asserts the exact hub-pool composition (10 jobs, band counts, weights in-band, `createdRealMs` = seed moment) plus a "board always offers jobs beyond the starter" pin; the replay test mirrors `generatePoolPackage`; five full-object equality tests pin `createdRealMs` to avoid millisecond-boundary flakes. E2E: `e2e/sim.spec.ts`'s expiry tests are wall-clock now (hook ages + ticks, then polls the pool back to target — no scale or clock activation); `e2e/right-pane.spec.ts` replaces the LEMD Load-disabled test with an absence assertion (a non-pool-origin open job never reaches `/api/state`); `e2e/hover.spec.ts`'s antimeridian pair moves NZAA→NFTF to EDDF→NFTF (same ±180 eastward crossing, sampled maxJump 359.98) because the hook must create jobs at a pool airport; `e2e/expiry.spec.ts` pins the new hook contract (aged job expired by response time, unknown id 404, already-expired 404).
|
||||||
|
|
||||||
|
## v0.64.0 — 2026-09-23 10:06
|
||||||
|
- **Market keeps topping up while the fleet is grounded (#135 follow-up).** Root cause of a soft-lock introduced by v0.62.0's frozen clock: in `tickOnce` (`lib/sim/engine.ts`) every package-CREATION path (market top-up + coverage spawn) sat behind the same `advanceMs > 0` gate as expiry/delivery/repairs, while removal paths did not — `ignorePackage` runs on any request regardless of advance. A fully grounded fleet therefore froze the clock AND stopped all spawning, so repeated ignores could drain the market below `MARKET_TARGET_OPEN_PACKAGES = 10` (down to zero) and no code path ever created new jobs again: a player with a grounded fleet and an empty market was stuck forever. Fix: only the two spawn calls moved OUT of the gate — `spawnOpenPackages` + `spawnCoveragePackages` now run on EVERY tick, including frozen ones, passing the current (possibly frozen) `clockMs`; `expirePackages`, `deliverPackages` and `finishRepairs` stay inside it. Both spawns were already idempotent no-ops when satisfied (count-gated at target / set-gated by uncovered grounded airports), so advancing ticks behave exactly as before and the clock stays EXACTLY frozen while grounded — only new packages appear, created at game time. No schema change, no migration, no other engine behavior touched.
|
||||||
|
- **Tests.** Unit: `lib/sim/engine.test.ts` — "a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)": fully grounded fleet (advance=false), open market drained to zero via direct DB writes, one tick → asserts BOTH that `sim.clockMs` is unchanged (freeze preserved) AND that the open count is back at ≥ `MARKET_TARGET_OPEN_PACKAGES` with ≥1 open package originating at the grounded starter's airport (EDDF coverage). E2E: `e2e/sim.spec.ts` — "a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)": self-heals to a fully grounded fleet, drains by calling `POST /api/packages/[id]/ignore` for every open package in repeated passes (pre-fix this reached zero and stayed there; post-fix each request's tick re-tops-up before its removal, so it can't), then `expect.poll`s that the open count from `/api/state` is above 0 within ~5 s of real time while asserting `simState.clockMs` stays EXACTLY equal to its pre-drain value and no plane took off — pinning both starvation-fixed and clock-still-frozen.
|
||||||
|
|
||||||
|
## v0.63.0 — 2026-09-23 08:43
|
||||||
|
- **Overlay colors retuned for the Fiord default (ISS-118).** The first half of this issue landed in v0.4.0 (`DEFAULT_STYLE_ID = 'fiord'` + top-bar switcher, e2e already pins `toHaveValue('fiord')`); this completes the second half — auditing every color drawn on the map against Fiord's actual palette. Key finding: OpenFreeMap's Fiord is a DARK cool-toned basemap (land #45516E slate, water #38435C navy — verified byte-identical between the self-hosted `public/styles/fiord.json` and `tiles.openfreemap.org/styles/fiord`), not a light one. WCAG contrast audit of all nine overlay colors against both base surfaces: seven already read fine (≥ 3.3:1 on land AND water) and are untouched — amber flying leg #f59e0b, gold dotted queue line #d4a017 (ISS-098 picked it for light+dark), sky-blue grounded plane + hover halo #38bdf8, green flying plane #22c55e, yellow hover ring/trace #facc15, green target ring #4ade80. Two were weak and got brightened one step within the same hue family: airport dot fill rose-600 #e11d48 (1.68:1 on slate land — the weakest element on the map) → rose-500 #f43f5e (2.15:1), and the hovered-route highlight red-500 #ef4444 (2.10:1, below the 3:1 graphical-object bar for an emphasis layer) → red-400 #f87171 (2.86:1 on land / 3.57:1 on water). White strokes and all other paint values unchanged; no new constants — both colors are single-use inline paint values, matching the existing style of this file.
|
||||||
|
- **Tests.** No test changes needed: `components/map.test.ts` pins only #d4a017 (untouched), e2e specs query rendered features by id/layer rather than color, and `lib/mapStyles.test.ts` already pins `DEFAULT_STYLE_ID = 'fiord'`.
|
||||||
|
|
||||||
|
## v0.62.0 — 2026-09-23 07:36
|
||||||
|
- **Fixed 24x world clock (#135).** Finalized the closed issue #117 spec: the world clock runs at the fixed `WORLD_TIME_SCALE = 24` while any plane is flying or in repair, and stays FROZEN when the whole fleet is grounded — replacing v0.56.0's idle slowdown (`IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24`, ISS-128). **Production rate is now exactly 24x regardless of legacy stored values:** `tickOnce` in `lib/sim/engine.ts` computes `advanceMs = advance ? min(elapsed, MAX_TICK_ELAPSED_MS) × scale : 0` where `scale = FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE` — pre-v0.52.0 saves hold `timeScale: 1000` (the old UI ladder), which is why the live instance still ran at ×1000; only deployments with the env var set honor the stored override written by `POST /api/test/set-timescale` (range [1, 100_000], kept for tests). A frozen tick skips expiry/delivery/repair/spawn entirely and only updates `lastRealTickMs`, so resuming activity never fast-forwards the frozen span. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) holds its anchor value while grounded, so polls re-anchor without any jump. Removed: `IDLE_WORLD_TIME_SCALE` from `lib/worldClock.ts`, and — **separate finding** — `FLIGHT_TEST_HOOKS: "1"` from the production `docker-compose.yml`: it enabled the `/api/test/*` hooks in prod AND (pre-gate) made the live instance honor its stale stored 1000; e2e runs only in CI against `docker-compose.ci.yml`, which sets the var itself. Unchanged on purpose: the ~1 s background tick (started at boot), the test hook endpoint, and the inert legacy columns `paused`/`lastSelectedTimeScale` (nothing reads them; no migration — per #117 saves reset when multiplayer ships). The OpenAPI description of `/api/state` ("while no plane is flying or in repair the clock stays frozen") now matches the code.
|
||||||
|
- **Tests.** Unit: `lib/sim/engine.test.ts` — both rate branches pinned: with `FLIGHT_TEST_HOOKS=1` stubbed (the file's default, like CI) stored overrides are honored; a new production test unsets the var and proves a legacy stored `timeScale: 1000` still advances at exactly ×24. Grounded-fleet tests pin a FROZEN clock (no idle creep, resume-without-catch-up after takeoff, raised timeScale ignored while grounded); describe renamed to "grounded fleet freezes the world clock (#135)" and pins `WORLD_TIME_SCALE = 24`. `lib/useSimClock.test.ts` — the grounded case asserts staying exactly at the anchor value. E2E: `e2e/sim.spec.ts`'s idle test becomes an exact freeze (delta === 0 after ~2.5 s of idleness); `e2e/state.spec.ts`'s restart test tightens from bounded delta to EXACT clock equality across `docker compose restart`; `e2e/right-pane.spec.ts` verifies the frozen clock, then asserts the abandon-penalty preview matches the server exactly (zero drift under a frozen clock — replaces the DRIFT_MS slope tolerance); `e2e/events.spec.ts` relaxes strict clock advance between SSE frames to non-decreasing (a grounded fleet pushes identical-clockMs frames; advance itself is pinned in sim.spec.ts). The activateWorld/deactivateWorld wrappers are unchanged — a repairing plane still means full scale.
|
||||||
|
|
||||||
|
## v0.61.0 — 2026-09-23 07:12
|
||||||
|
- **Special remote airports (ISS-097).** The world dataset was curated to `large_airport` only, so genuinely empty regions — Antarctica, the high Arctic, Alaska, northern Canada and far islands — had no markers at all. New pure function `isRemote(airports, icao)` in `lib/airports.ts` (reusing the existing `haversineKm` from `lib/geo.ts`): an airport is remote when no other member of the given list lies within `REMOTE_RADIUS_KM = 1000`. The curation in `scripts/build-world-airports.mjs` now runs over ALL fixed-wing rows: every large_airport (unchanged clustering) plus every non-large row that is remote against the kept set; same-station twins (two strips <40 km apart at one base) collapse via the same greedy 40 km clustering. `data/airports_world.csv` grows from 1119 → **1483 rows** (+364 remote airports), including Rothera (EGAR), Halley (EGAH) and Showa skiway (AT25) in Antarctica; Grise Fiord (CYGZ), Alert (CYLT), Ny-Ålesund (ENAS — Svalbard's only marker within 1000 km of a large airport) and Wrangel Island (UHMZ) in the Arctic; Bethel (PABR) and other Alaska strips; Canadian-Arctic smalls; and far islands such as Easter (SCIP), Canton (PCIS), Henderson/Pago Pago (PMDY) and Ascension (FHAW). The only change to a pre-existing row is an upstream `home_link` refresh on CYLW. New airports become contract origins/destinations and map markers unchanged — the most isolated airport in the new set (Easter Island) has its nearest neighbour 2599 km away, well under the 9000 km job cap, so every origin stays flyable; `filterWorldAirports` already accepts small/medium/seaplane types, so no runtime change was needed.
|
||||||
|
- **Tests.** Unit +4 in `lib/airports.test.ts`: a synthetic radius boundary (equator pair at ~945 km vs ~1052 km), real coordinates (Frankfurt↔Amsterdam is not remote; McMurdo vs Christchurch is), an unknown ICAO returning false, and the committed-dataset curation invariant — every non-large row has no large airport within 1000 km and at least one non-large row exists.
|
||||||
|
|
||||||
|
## v0.59.0 — 2026-09-23 05:48
|
||||||
|
- **SSE frames no longer carry package history (ISS-133).** Measured on the live instance first: each `/api/events` frame was compact JSON at ~597 KB, pushed once per real second regardless of time scale (the background tick is a fixed 1 s interval; ×1000 just advances the clock further per frame) — and 99.8 % of it was `packages`: 1,521 rows because terminal packages are never deleted from the DB (expired 564, delivered 528, ignored 386, abandoned 31), while only 12 were `open`. No component ever reads a package with a terminal status — the market list renders `open`, and on-board cargo is `loaded`/`expired` (`LeftPane`, `cargoWeightFor`) — so the history was pure re-sent weight. New `sseFrame(snapshot)` in `lib/game/state.ts` projects a snapshot down to the client-rendered statuses (`SSE_PACKAGE_STATUSES = open|loaded|expired`); both SSE paths use it — the broadcast in `lib/sim/engine.ts` and the initial frame in `app/api/events/route.ts`, so a connect never costs one full-history frame. Live payload drops ~597 KB → ~10 KB per frame (≈ 60×), bounded forever by fleet/market size instead of growing with play time. `/api/state` and action responses keep the FULL package set unchanged, so every e2e oracle that asserts `delivered`/`abandoned` transitions via state lookups still works; the polling fallback stays a strict superset of what SSE pushes. Deliberately NOT done: hash-skip broadcasts (the snapshot's `clockMs` advances on every tick even while idle — `advanceMs = min(elapsed, 2000 ms) × scale ≥ 1 s` — so no two frames are ever equal), throttling (already exactly 1 Hz), and a diff/patch engine.
|
||||||
|
- **Tests.** Unit +3 in new `lib/game/state.test.ts`: the projection keeps exactly open/loaded/expired (order preserved, terminal rows dropped), passes account/simState/planes through untouched without mutating its input, and pins the status set itself. E2E: `e2e/events.spec.ts` now records every package status seen in any frame plus the largest frame — asserts no terminal status ever appears and frames stay under 200 KB (a full-history regression at ~597 KB fails loudly).
|
||||||
|
|
||||||
|
## v0.58.0 — 2026-09-22 21:27
|
||||||
|
- **Plane markers no longer teleport across the antimeridian (ISS-124).** The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (`positionAt` wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. `planesGeoJson` in `lib/mapData.ts` now takes the airport index (`airportsByIcao`, same parameter shape as `routesGeoJson`) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor `greatCircleArc` keeps for its first sample — via a new module-private `unwrapLon(lon, prev)` (the ±360 shift until |Δlon| ≤ 180 that `unwrapLons`'s loop body already used; `unwrapLons` now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring `routesGeoJson`'s guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). `components/Map.tsx` passes `airportsIndexRef.current ?? new Map()` at both call sites (`applyDynamic` and the source bootstrap in `ensureLayers`) — planes draw immediately at their wrapped position until the airport index loads, then unwrap.
|
||||||
|
- **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state.
|
||||||
|
- **E2E fix (CI run 176).** The new antimeridian test failed deterministically in CI — not a marker bug, but a query trap: MapLibre's `querySourceFeatures` only returns features in LOADED tiles, and GeoJSON sources load per viewport tile; the default camera sits over Europe while NZAA/NFTF are in the Pacific, so the plane feature never appeared in queries and the "flying" poll timed out (the same pattern passes for EDDF→LEMD because that leg is inside the default view). The test now fits the camera over both endpoints first via `fitToAirports` (copied from `e2e/hover.spec.ts`, including its ISS-120 unwrap so a ±180-straddling pair bounds the Pacific, not the Atlantic) before polling and sampling. Pollution guard: relocation + crossing now run in a `try/finally` whose cleanup ALWAYS runs — it waits for any in-flight leg to land (scale 1000), then chains the plane back to EDDF from wherever it is, so a failed poll can no longer leave the starter FLYING and 409-cascade into the eleven later specs' fly-home helpers (the run-176 cascade). The dispatch POST's status is now checked explicitly with a clear message instead of a bare `expect(...).toBe(200)`. Unit +1 in `lib/mapData.test.ts` pins the exact feature properties the e2e `starterMarker` matches on (`properties.id === 'plane-starter'`, `properties.flying === true`).
|
||||||
|
- **E2E fix (CI run 177).** The antimeridian test still failed — again not a marker bug: by dispatch time the test's page had been alive ~3 min through the relocation chain, and under dind load the browser's state feed can go stale (the SSE fallback in `lib/useGameState.ts` only re-arms polling on stream error or a one-shot 5 s silence check at connect), leaving the planes source frozen on an old FLYING frame from a relocation hop — which fooled the "wait for flying" poll and made every sampled longitude a stale pre-crossing value (continuous, all ≤ 180, never > 180). The test now reloads the page after relocation (server state persists; on the fresh page only the crossing leg can ever produce a flying frame), and the sampling loop gains a freshness gate: a sample is recorded only if its marker position is within ~700 km of where the SERVER snapshot says the plane is (~5 s of flight at ×1000 — haversine is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly), so a frozen frame can no longer pass continuity vacuously; `starterMarker` now also returns latitude for that check. Pre-existing flake fixed in the same run: `e2e/queue.spec.ts`'s chain-refuel lower bound loosened from `1.4 × required2` to `1.3 × required2` — under dind load the post-departure snapshot can land ~0.1 × required2 into leg 2's burn (observed miss: 0.07 %); a wrong top-up level still lands far below the bound, and the exact level remains pinned by the upper bound plus the rep-discounted cash assertion.
|
||||||
|
- **Root cause of the stale-feed freeze fixed in `lib/useGameState.ts` (CI run 178).** The reload + freshness gate above were insurance; run 178 proved the feed can freeze even on a FRESH page — and the reason is now fixed at the source, not worked around. The SSE silence check was one-shot: `setTimeout(startPolling, 5 s)` armed once at connect, cleared by the FIRST event and never re-armed. After that, only a stream ERROR could restart polling — but a half-open connection that simply stops delivering data (observed under dind load; `/api/state` on separate connections kept answering fine throughout) fires no `onerror`, so state froze until reload. The timer is now re-armed on EVERY message: any 5 s gap in the event flow — whenever it happens, not just before the first event — starts the polling fallback (immediate catch-up fetch + interval), which stops again when fresh events resume. Healthy streams pay one set/clearTimeout per event; stalled ones degrade to the pre-v0.57.0 poll loop instead of freezing. The e2e keeps both insurance layers: the reload still guarantees a clean SSE connect at dispatch, and the freshness gate still fails loudly if any future stall outlasts it (a frozen source can no longer pass continuity vacuously).
|
||||||
|
- **Tests.** Unit +1 in `lib/mapData.test.ts` ("NZAA→NFTF crossing leg — full pipeline on stored dataset coords"): runs the exact production pipeline on the STORED CSV coordinates (`loadAirportsGeoJson` → `positionAt` slerp sampled at 51 steps → `planesGeoJson`) and pins what a live browser source must show — marker lons monotonically increasing from +174.8 to +184.9 (the unwrapped NFTF), every consecutive pair < 180° apart, max > 180 (eastward crossing onto the world copy). This is the ground truth the e2e's `lon > 180` assertion encodes: the dataset crosses EASTWARD (NZAA +174.786 → NFTF −175.149, Δ ≈ +10°), so a correct pipeline MUST produce lons above +180 — and any e2e failure of that assertion is now provably a stale browser source, not the math.
|
||||||
|
|
||||||
|
## v0.57.0 — 2026-09-22 19:19
|
||||||
|
- **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: <json>\n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional).
|
||||||
|
- **API.** OpenAPI spec gains `GET /api/events` (200 → `text/event-stream`) in `lib/openapi.ts`, so the machine-readable spec at `/api/openapi` stays accurate.
|
||||||
|
- **Tests.** Unit: new `lib/sse.test.ts` — subscribe/broadcast/unsubscribe semantics, a throwing subscriber doesn't break others, idempotent unsubscribe; new `app/api/events/route.test.ts` (throwaway-DB pattern from the state route test) — SSE headers + first chunk parses as a snapshot with `simState.clockMs`. E2E: new `e2e/events.spec.ts` (+1) — node-side fetch of `/api/events` against the app under test asserts content-type `text/event-stream`, the first event parses as a valid snapshot (`simState.clockMs`), and within ~5 s at least one MORE event arrives with an advanced clock (background tick pushes while idle since v0.56.0 — no scale hook); stream closed in `finally`. The exact-endpoint-list assertions in `lib/openapi.test.ts` and `e2e/api.spec.ts` move twelve → thirteen endpoints (`/api/events` added).
|
||||||
|
|
||||||
|
## v0.56.0 — 2026-09-22 18:28
|
||||||
|
- **Idle fleet slows the world to real time instead of freezing it (ISS-128).** A fully grounded fleet no longer freezes the sim clock — `tickOnce` in `lib/sim/engine.ts` now advances by `min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE` when nothing is flying or in repair, where new constant `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24` (= exactly 1, i.e. real time) lives next to `WORLD_TIME_SCALE` in `lib/worldClock.ts`. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and `lastRealTickMs` still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump.
|
||||||
|
- **Tests.** Unit: `lib/sim/engine.test.ts` — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1`. `lib/useSimClock.test.ts` — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: `e2e/sim.spec.ts`'s freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); `e2e/state.spec.ts`'s restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ `MAX_TICK_ELAPSED_MS` of clamped downtime + the live wall span; `e2e/right-pane.spec.ts`'s penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The `activateWorld`/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated.
|
||||||
|
|
||||||
|
## v0.55.0 — 2026-09-22 15:38
|
||||||
|
- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `<link rel="icon">` into the head; no layout/metadata changes needed.
|
||||||
|
- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset.
|
||||||
|
- **E2E hardening (CI run #167).** Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon `<link href>` (`/icon.svg?icon.<hash>.svg` — matched with `/^\/icon\.svg/`, canonical `GET /icon.svg` check unchanged), and `e2e/hover.spec.ts`'s local `flyHomeViaApi` self-heal is replaced by the established `e2e/panes.spec.ts` pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked `locationIcao` against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin").
|
||||||
|
- **E2E hardening (CI run #168).** Retry-cascade fix in `e2e/actions.spec.ts`: its local `flyHomeViaApi` self-heal now also tops plane-starter's tank back to full via the free `POST /api/test/set-fuel` hook after repositioning home. A transient `/api/state` 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0.
|
||||||
|
- **E2E hardening (ISS-127).** e2e `getState` hardened against transient `/api/state` 500s — internal retry ×3/500 ms keeps poll callbacks total.
|
||||||
|
|
||||||
|
## v0.54.0 — 2026-09-22 15:20
|
||||||
|
- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
|
||||||
|
- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.
|
||||||
|
|
||||||
|
## v0.53.0 — 2026-09-22 14:47
|
||||||
|
- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
|
||||||
|
- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
|
||||||
|
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
|
||||||
|
|
||||||
|
## v0.52.0 — 2026-09-22 10:45
|
||||||
|
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
|
||||||
|
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
|
||||||
|
- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve).
|
||||||
|
|
||||||
|
## v0.51.0 — 2026-09-22 08:21
|
||||||
|
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
|
||||||
|
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
|
||||||
|
|
||||||
|
## v0.50.0 — 2026-09-22 08:00
|
||||||
|
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
|
||||||
|
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
|
||||||
|
|
||||||
|
## v0.49.0 — 2026-09-22 07:25
|
||||||
|
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
|
||||||
|
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
|
||||||
|
|
||||||
|
## v0.48.0 — 2026-09-22 06:55
|
||||||
|
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
||||||
|
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
||||||
|
|
||||||
|
## v0.47.0 — 2026-09-21 23:01
|
||||||
|
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
|
||||||
|
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
|
||||||
|
|
||||||
|
## v0.46.0 — 2026-09-21 22:32
|
||||||
|
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
|
||||||
|
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
|
||||||
|
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
|
||||||
|
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
|
||||||
|
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
|
||||||
|
|
||||||
|
## v0.45.0 — 2026-09-21 21:12
|
||||||
|
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
|
||||||
|
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
|
||||||
|
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
|
||||||
|
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
|
||||||
|
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
|
||||||
|
|
||||||
|
## v0.44.0 — 2026-09-21 17:10
|
||||||
|
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
|
||||||
|
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
|
||||||
|
- **Generated jobs are distance-capped.** New `MAX_JOB_LEG_KM = 9000` in `lib/game/seed.ts`: every spawned leg is flyable by at least one catalog plane even in worst-case headwind (b777 full-tank range ~13045 km nominal, ~10871 km with the +20% wind factor — 9000 leaves margin). `generatePackage` rejection-samples unforced origin/dest pairs until they fit (bounded at 25 attempts, then a uniform within-cap fallback); forced endpoints consume no extra draws and stay uncapped (the UNNT→LPPD 409 backstop relies on that), so under-cap RNG streams are byte-identical to before. The engine's coverage spawn caps its random dest through the shared `pickDestWithinCap` helper — one draw in the same stream position as before
|
||||||
|
- **UI/spec/docs.** Map header label → "N world airports" (e2e regex updated), OpenAPI `/api/airports` summary/description reworded to the curated world dataset, PLAN.md dataset mentions updated; initial camera stays EU-tuned (out of scope)
|
||||||
|
- **E2E marker isolation re-verified.** LFRS (Nantes) keeps ≥39 px clearance from its nearest markers (LFRB Brest / LFBD Bordeaux) at the initial zoom [10,52] z4 with the world dataset — no test changes needed beyond the label regex
|
||||||
|
## v0.43.0 — 2026-09-21 15:57
|
||||||
|
- **Wear rate reduced 10× per ISS-085** — `WEAR_PER_KM` in `lib/game/catalog.ts` drops from 0.005 to 0.0005, so plane maintenance status degrades 1/10th as fast (~13 mid-range hops → ~130 hops to break a full-health plane)
|
||||||
|
|
||||||
|
## v0.42.0 — 2026-09-21 15:02
|
||||||
|
- **Auto-refuel on dispatch (ISS-082).** Manual refueling is gone — planes top up automatically at dispatch, so a grounded plane can always start any leg that fits a full tank when cash allows. `flyPlane` (`lib/game/actions.ts`) tops the tank to `min(maxFuel, max(fuel, 1.5 × required))` before departure — the same target/price terms as the removed forFlight mode: liters × FUEL_PRICE_PER_L minus the rep-scaled discount (v0.38.0), deducted from cash; unaffordable → 409 `insufficient funds` with nothing mutated, and a leg that doesn't fit even a full tank still 409s `insufficient fuel` (the backstop). The engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) does the same at landing: when the next queued leg exceeds the landed fuel it tops up to the identical target when cash allows and chains anyway — only an unaffordable top-up grounds the plane with the queue kept intact
|
||||||
|
- **REMOVED** `POST /api/planes/:planeId/refuel` (route + route tests), the `refuelPlane()` action + `RefuelOptions` (`lib/game/actions.ts`), the left-pane "Refuel to full" section and per-leg Refuel button with their dead state/derived values (`components/LeftPane.tsx`), and the OpenAPI `/api/planes/{planeId}/refuel` path + `RefuelRequest` schema — endpoint count 14 → 13, stale refuel mentions reworded in info/state/queue/repair descriptions, and fly's description + 409 now document the auto-refuel
|
||||||
|
- **Test hook.** New POST `/api/test/set-fuel` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, fuel }` forces a plane's fuel — finite number in [0, maxFuel] (400 outside / unknown field), 404 unknown plane; sibling of set-health
|
||||||
|
- **Tests.** Unit: `refuelPlane` suite deleted; new `flyPlane` auto-refuel tests (exact top-up target + rep-discounted cost at rep 30, free no-op on a full tank, cash 0 → 409 insufficient funds with fuel unchanged, UNNT→LPPD backstop still 409s); the broken-burn test reworked to pin that ×1.5 burn raises the top-up target; `engine.test.ts`'s chain test flipped — a short tank now chains via an affordable top-up (post-top-up fuel + exact cost from the real helpers) plus a new cash-0 variant grounding with the queue intact. E2E: every `/refuel` setup/retry site (~25 across 9 specs) replaced by the set-fuel hook, heal helpers drop their refuel-and-retry guard (dispatch auto-refuels), right-pane's per-leg Refuel test and two-planes' "refuel section lists both planes" deleted in favor of one new end-to-end dispatch auto-top-up test (set-fuel drops fuel to 500 L → fly → exact cash delta from snapshot + real helpers, landed fuel = weighted burn from the topped start value), panes.spec.ts now asserts Refuel-UI absence in both panes, api.spec.ts pins thirteen endpoints
|
||||||
|
|
||||||
|
## v0.41.0 — 2026-09-21 13:26
|
||||||
|
- **Queue UI fixes (ISS-077).** Three queue-related map/UI fixes, no server logic changed:
|
||||||
|
- **Would-409 controls are disabled.** The on-board "Fly to …" button (`components/LeftPane.tsx`) is additionally disabled while the plane is flying and its destination is already active or queued (title "Already in flight queue"); the Relocate section disables its GO button for the same reason AND renders the selected plane's active/queued airports as native disabled `<option>`s. Grounded planes are untouched — their clicks go to `/fly`, which has no such conflict
|
||||||
|
- **Hover trace starts at the landing airport.** Hovering an on-board package while its carrier is FLYING now traces from the carrier's LANDING airport (`destIcao`), not its live mid-flight position (`components/Map.tsx`) — the hover previews what happens AFTER this leg lands; the solid active-leg + dotted queue layers already show where it is. Grounded / no-active-leg carriers keep today's behavior exactly (live position = the plane's airport). `hoverTraceGeoJson` returns an empty collection when start == dest, so a package destined for the active leg's destination draws nothing there — the solid layer covers it
|
||||||
|
- **Grounded queues draw dotted lines.** `queuedRoutesGeoJson` (`lib/mapData.ts`) now also draws GROUNDED ('available') planes with a non-empty queue, chaining from their current airport (`locationIcao → queue[0] → …`); flying planes keep the `[destIcao, ...queue]` chain; 'repairing' planes draw nothing. Same source/layer — no new MapLibre layer
|
||||||
|
- **Tests.** Unit: `lib/mapData.test.ts` pins the grounded chain (exact endpoints), the unknown-first-point skip, the repairing skip and the start==dest empty trace; all flying-case pins stay green. E2E: `e2e/hover.spec.ts`'s mid-flight test is reworked — a package destined for a QUEUED stop beyond the active leg, hover traces from the landing airport to that stop with no segment starting at the departure airport (deterministic: paused right after dispatch); new `e2e/queue.spec.ts` test pins the grounded two-leg chain HUB → DEST → QUEUE in the `queue-routes` layer
|
||||||
|
|
||||||
|
## v0.40.0 — 2026-09-21 13:06
|
||||||
|
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
||||||
|
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
||||||
|
|
||||||
|
## v0.39.0 — 2026-09-21 12:02
|
||||||
|
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
|
||||||
|
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
|
||||||
|
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
|
||||||
|
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
|
||||||
|
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
|
||||||
|
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
|
||||||
|
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
|
||||||
|
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
|
||||||
|
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
|
||||||
|
|
||||||
|
## v0.38.0 — 2026-09-21 10:47
|
||||||
|
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
|
||||||
|
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
|
||||||
|
- **Cheaper refuel** — `refuelPlane` multiplies ONLY the price term: `liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2))`; burn/target math (wind factor, ×1.5 forFlight margin) untouched
|
||||||
|
- **Better jobs at higher reputation** — `generatePackage` (`lib/game/seed.ts`) takes an optional `reputation` input (defaults to the starting value) and multiplies the final reward by `1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0)` at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
|
||||||
|
- **Spawn sites carry live reputation.** `spawnOpenPackages` + `spawnCoveragePackages` (`lib/sim/engine.ts`) read the account row once per spawn batch and thread its reputation into `generatePackage`; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). `buildSeed` keeps defaulting — no rep at seed time
|
||||||
|
- **UI shows the effect.** The market buy row (`components/RightPane.tsx`) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (`market-buy-discount`, reusing `.special-badge`) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (`components/LeftPane.tsx`) previews the discounted fill-up cost with its own `refuel-discount` pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
|
||||||
|
- **OpenAPI (descriptions only).** buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; `CatalogEntry.price`, `StateSnapshot.fuelPricePerL` and `AccountState.reputation` gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
|
||||||
|
- **Tests.** New unit tests: `repRate` (zero at/below start, linear mid-range exact values, cap clamping), `buyPlane` at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), `refuelPlane` at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), `generatePackage` job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in `right-pane.spec.ts`, `two-planes.spec.ts` and `actions.spec.ts` now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)
|
||||||
|
|
||||||
|
## v0.37.0 — 2026-09-21 09:29
|
||||||
|
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
|
||||||
|
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
|
||||||
|
- **Rep bonus at delivery.** `deliverPackages` (`lib/sim/engine.ts`) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
|
||||||
|
- **State shape & DB.** `special: string | null` added in all four places — `SeedPackage`, Drizzle schema (`lib/db/schema.ts`), raw DDL and a guarded nullable `ALTER TABLE … ADD COLUMN special TEXT` migration in `migrateColumns()` (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and `PackageState` + its row mapping
|
||||||
|
- **UI badge.** Open-package rows (`components/RightPane.tsx`) and on-board rows (`components/LeftPane.tsx`) show a sky-blue "Priority" pill next to the route when the package has a special status (new `.special-badge` class, `package-special-{id}` testid); label via new `formatSpecialLabel` in `lib/format.ts`
|
||||||
|
- **Test hooks & spec.** POST /api/test/create-package accepts `special` (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes `special: null` explicitly. OpenAPI PackageState gains optional `special: string | null`
|
||||||
|
- **Tests.** New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces `special: 'priority'` via the hook and asserts the row shows the "Priority" badge
|
||||||
|
- **E2E self-heal hardening.** The `flyHomeViaApi` helper in `e2e/right-pane.spec.ts` now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and `e2e/slow-on-landing.spec.ts`'s equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on `e2e/two-planes.spec.ts`)
|
||||||
|
|
||||||
|
## v0.36.0 — 2026-09-21 08:45
|
||||||
|
- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
|
||||||
|
- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
|
||||||
|
- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note)
|
||||||
|
|
||||||
|
## v0.35.0 — 2026-09-21 02:26
|
||||||
|
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
|
||||||
|
|
||||||
|
## v0.34.0 — 2026-09-21 01:26
|
||||||
|
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
|
||||||
|
|
||||||
|
## v0.33.0 — 2026-09-21 00:45
|
||||||
|
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
|
||||||
|
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
|
||||||
|
|
||||||
|
## v0.32.0 — 2026-09-20 23:47
|
||||||
|
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
|
||||||
|
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
|
||||||
|
|
||||||
|
## v0.31.0 — 2026-09-20 21:23
|
||||||
|
- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
|
||||||
|
- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`)
|
||||||
|
- **New test hook: POST /api/test/add-cash (ISS-055).** Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body `{ amount?: number }` (positive finite, default €1M), gated on `FLIGHT_TEST_HOOKS=1` exactly like its siblings (`app/api/test/create-package`, `/expire-package`; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen
|
||||||
|
- **New serial e2e spec `e2e/two-planes.spec.ts` (ISS-055).** Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't `plane-starter`: **load** (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), **fly** (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves `paused === false && timeScale === 1`, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes `lastSelectedTimeScale` = 1000 in the /fly response), **refuel** (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), **relocate** (moves one plane, starter untouched), **queue** (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), **sell** (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); `npx playwright test --list` = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.30.0 — 2026-09-20 19:47
|
||||||
|
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
|
||||||
|
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
|
||||||
|
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
|
||||||
|
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
|
||||||
|
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.29.1 — 2026-09-20 18:22
|
||||||
|
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
|
||||||
|
|
||||||
|
## v0.29.0 — 2026-09-20 14:27
|
||||||
|
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
|
||||||
|
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
|
||||||
|
- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
|
||||||
|
- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
|
||||||
|
- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
## v0.28.0 — 2026-09-20 13:55
|
## v0.28.0 — 2026-09-20 13:55
|
||||||
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
|
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
|
||||||
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
|
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
|
import { DELETE, GET, POST, PUT } from './route';
|
||||||
|
|
||||||
|
// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables),
|
||||||
|
// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input).
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let adminSession = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// adminSession is minted in beforeAll, so the header helpers read it at call time.
|
||||||
|
const authHeaders = () => ({ 'X-Flight-Session': adminSession });
|
||||||
|
|
||||||
|
// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly.
|
||||||
|
const ctx = (name: string) => ({ params: Promise.resolve({ name }) });
|
||||||
|
|
||||||
|
const getRows = (name: string, query = '') =>
|
||||||
|
GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name));
|
||||||
|
const putRow = (name: string, body: unknown) =>
|
||||||
|
PUT(
|
||||||
|
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
ctx(name),
|
||||||
|
);
|
||||||
|
const postRow = (name: string, body: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
ctx(name),
|
||||||
|
);
|
||||||
|
const deleteRow = (name: string, rowid: number | null) =>
|
||||||
|
DELETE(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`,
|
||||||
|
{ method: 'DELETE', headers: authHeaders() },
|
||||||
|
),
|
||||||
|
ctx(name),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('admin rows CRUD', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET returns columns + rows with __rowid for the PK-less sim_state table', async () => {
|
||||||
|
const res = await getRows('sim_state');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { columns: string[]; rows: Array<Record<string, unknown>> };
|
||||||
|
expect(body.columns[0]).toBe('__rowid');
|
||||||
|
for (const col of ['clock_ms', 'time_scale']) expect(body.columns).toContain(col);
|
||||||
|
expect(body.rows).toHaveLength(1); // the single seeded sim_state row
|
||||||
|
expect(typeof body.rows[0].__rowid).toBe('number');
|
||||||
|
expect(typeof body.rows[0].clock_ms).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT updates a cell and the change survives a re-fetch', async () => {
|
||||||
|
const before = await (await getRows('sim_state')).json();
|
||||||
|
const originalClockMs = (before as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms;
|
||||||
|
|
||||||
|
const res = await putRow('sim_state', { rowid: 1, values: { clock_ms: 424_242 } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
|
|
||||||
|
// Re-fetch through the same route — the persisted value is back.
|
||||||
|
const body = await (await getRows('sim_state')).json();
|
||||||
|
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms).toBe(424_242);
|
||||||
|
|
||||||
|
// Restore the original clock so later tests see a sane world.
|
||||||
|
const restore = await putRow('sim_state', { rowid: 1, values: { clock_ms: originalClockMs } });
|
||||||
|
expect(restore.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT rejects unknown columns and empty values with 400', async () => {
|
||||||
|
const badCol = await putRow('sim_state', { rowid: 1, values: { no_such_column: 1 } });
|
||||||
|
expect(badCol.status).toBe(400);
|
||||||
|
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||||
|
|
||||||
|
const empty = await putRow('sim_state', { rowid: 1, values: {} });
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
|
||||||
|
const noRowid = await putRow('sim_state', { values: { clock_ms: 1 } });
|
||||||
|
expect(noRowid.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST inserts a row and DELETE removes it again (users table)', async () => {
|
||||||
|
const created = await postRow('users', {
|
||||||
|
values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() },
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const body = (await created.json()) as { ok: boolean; rowid: number };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(Number.isInteger(body.rowid)).toBe(true);
|
||||||
|
|
||||||
|
// Visible via GET…
|
||||||
|
let list = await (await getRows('users')).json();
|
||||||
|
const rows = (list as { rows: Array<Record<string, unknown>> }).rows;
|
||||||
|
expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true);
|
||||||
|
|
||||||
|
// …and gone after DELETE.
|
||||||
|
const del = await deleteRow('users', body.rowid);
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
list = await (await getRows('users')).json();
|
||||||
|
expect((list as { rows: Array<Record<string, unknown>> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false);
|
||||||
|
|
||||||
|
// DELETE is idempotent.
|
||||||
|
const again = await deleteRow('users', body.rowid);
|
||||||
|
expect(again.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST rejects unknown columns and empty values with 400', async () => {
|
||||||
|
const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } });
|
||||||
|
expect(badCol.status).toBe(400);
|
||||||
|
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||||
|
|
||||||
|
const empty = await postRow('users', { values: {} });
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE requires an integer rowid query parameter', async () => {
|
||||||
|
const missing = await deleteRow('users', null);
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
const bad = await DELETE(
|
||||||
|
new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }),
|
||||||
|
ctx('users'),
|
||||||
|
);
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown table on every verb', async () => {
|
||||||
|
const g = await getRows('no_such_table');
|
||||||
|
expect(g.status).toBe(404);
|
||||||
|
const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } });
|
||||||
|
expect(p.status).toBe(404);
|
||||||
|
const i = await postRow('no_such_table', { values: { x: 1 } });
|
||||||
|
expect(i.status).toBe(404);
|
||||||
|
const d = await deleteRow('no_such_table', 1);
|
||||||
|
expect(d.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-integer limit with 400 and caps at 1000', async () => {
|
||||||
|
const bad = await getRows('users', '?limit=abc');
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
const capped = await getRows('users', '?limit=999999');
|
||||||
|
expect(capped.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => {
|
||||||
|
const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users'));
|
||||||
|
expect(noHeader.status).toBe(401);
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run(
|
||||||
|
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||||
|
SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`,
|
||||||
|
[Date.now()],
|
||||||
|
);
|
||||||
|
const nonAdminSession = await createSession(inst, 'user-rows-nonadmin');
|
||||||
|
const forbidden = await GET(
|
||||||
|
new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }),
|
||||||
|
ctx('users'),
|
||||||
|
);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { persist } from '@/lib/db/client';
|
||||||
|
import type { GameDb } from '@/lib/db/client';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any
|
||||||
|
// user input is touched). SQL safety: table names are quoted only after validation against
|
||||||
|
// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE
|
||||||
|
// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string.
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 500;
|
||||||
|
const MAX_LIMIT = 1000;
|
||||||
|
|
||||||
|
function quoteIdent(name: string): string {
|
||||||
|
return '"' + name.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app's own tables (sqlite_% internals excluded) — the only names ever accepted.
|
||||||
|
function tableNames(inst: GameDb): Set<string> {
|
||||||
|
const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
||||||
|
return new Set(res[0].values.map((r) => String(r[0])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column names of a validated table (name already checked against sqlite_master).
|
||||||
|
function columnsOf(inst: GameDb, name: string): string[] {
|
||||||
|
const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`);
|
||||||
|
return res[0].values.map((r) => String(r[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized execute — the only way values reach SQL in this file.
|
||||||
|
function run(inst: GameDb, sql: string, values: unknown[]): void {
|
||||||
|
inst.raw.run(sql, values as never[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req: Request): Promise<Record<string, unknown> | Response> {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} 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 (!ALLOWED_BODY_FIELDS.has(key)) {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-method top-level body fields (strict, like the /api/test/* hooks).
|
||||||
|
const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']);
|
||||||
|
|
||||||
|
function valuesObject(input: Record<string, unknown>): Record<string, unknown> | Response {
|
||||||
|
const values = input.values;
|
||||||
|
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
|
||||||
|
return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return values as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit;
|
||||||
|
// anything else is a server error.
|
||||||
|
function sqlError(err: unknown): Response {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) {
|
||||||
|
return Response.json({ error: `database constraint: ${msg}` }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error('admin rows route failed', err);
|
||||||
|
return Response.json({ error: 'database error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default
|
||||||
|
// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable
|
||||||
|
// like any other. Unknown table → 404; bad limit → 400.
|
||||||
|
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const guard = await requireAdmin(req);
|
||||||
|
if (!guard.ok) return guard.response;
|
||||||
|
|
||||||
|
const { name } = await ctx.params;
|
||||||
|
if (!tableNames(guard.inst).has(name)) {
|
||||||
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||||
|
}
|
||||||
|
let limit = DEFAULT_LIMIT;
|
||||||
|
const limitParam = new URL(req.url).searchParams.get('limit');
|
||||||
|
if (limitParam !== null) {
|
||||||
|
limit = Number(limitParam);
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) {
|
||||||
|
return Response.json({ error: 'limit must be a positive integer' }, { status: 400 });
|
||||||
|
}
|
||||||
|
limit = Math.min(limit, MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = columnsOf(guard.inst, name);
|
||||||
|
const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`);
|
||||||
|
try {
|
||||||
|
stmt.bind([limit]);
|
||||||
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
|
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
|
||||||
|
return Response.json({ columns: ['__rowid', ...cols], rows });
|
||||||
|
} catch (err) {
|
||||||
|
return sqlError(err);
|
||||||
|
} finally {
|
||||||
|
stmt.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the
|
||||||
|
// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty
|
||||||
|
// values → 400.
|
||||||
|
export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const guard = await requireAdmin(req);
|
||||||
|
if (!guard.ok) return guard.response;
|
||||||
|
|
||||||
|
const input = await readBody(req);
|
||||||
|
if (input instanceof Response) return input;
|
||||||
|
const rowid = input.rowid;
|
||||||
|
if (!Number.isInteger(rowid)) {
|
||||||
|
return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const values = await valuesObject(input);
|
||||||
|
if (values instanceof Response) return values;
|
||||||
|
|
||||||
|
const { name } = await ctx.params;
|
||||||
|
if (!tableNames(guard.inst).has(name)) {
|
||||||
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const cols = columnsOf(guard.inst, name);
|
||||||
|
for (const col of Object.keys(values)) {
|
||||||
|
if (!cols.includes(col)) {
|
||||||
|
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', ');
|
||||||
|
try {
|
||||||
|
run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [
|
||||||
|
...Object.values(values),
|
||||||
|
rowid,
|
||||||
|
]);
|
||||||
|
await persist(guard.inst);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sqlError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with
|
||||||
|
// exactly the given columns (others take their defaults or NULL). Returns the new rowid.
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const guard = await requireAdmin(req);
|
||||||
|
if (!guard.ok) return guard.response;
|
||||||
|
|
||||||
|
const input = await readBody(req);
|
||||||
|
if (input instanceof Response) return input;
|
||||||
|
const values = await valuesObject(input);
|
||||||
|
if (values instanceof Response) return values;
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await ctx.params;
|
||||||
|
if (!tableNames(guard.inst).has(name)) {
|
||||||
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const cols = columnsOf(guard.inst, name);
|
||||||
|
for (const col of Object.keys(values)) {
|
||||||
|
if (!cols.includes(col)) {
|
||||||
|
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(values);
|
||||||
|
try {
|
||||||
|
run(
|
||||||
|
guard.inst,
|
||||||
|
`INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
|
Object.values(values),
|
||||||
|
);
|
||||||
|
// Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets
|
||||||
|
// last_insert_rowid to 0 on this connection.
|
||||||
|
const res = guard.inst.raw.exec('SELECT last_insert_rowid()');
|
||||||
|
await persist(guard.inst);
|
||||||
|
return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) });
|
||||||
|
} catch (err) {
|
||||||
|
return sqlError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404;
|
||||||
|
// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent).
|
||||||
|
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const guard = await requireAdmin(req);
|
||||||
|
if (!guard.ok) return guard.response;
|
||||||
|
|
||||||
|
const { name } = await ctx.params;
|
||||||
|
if (!tableNames(guard.inst).has(name)) {
|
||||||
|
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const rowidParam = new URL(req.url).searchParams.get('rowid');
|
||||||
|
const rowid = Number(rowidParam);
|
||||||
|
if (rowidParam === null || !Number.isInteger(rowid)) {
|
||||||
|
return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]);
|
||||||
|
await persist(guard.inst);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sqlError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
|
import { GET } from './route';
|
||||||
|
|
||||||
|
// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403,
|
||||||
|
// admin passes and sees every user table (sqlite_% excluded).
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = (sessionId?: string) =>
|
||||||
|
GET(
|
||||||
|
new Request('http://localhost/api/admin/tables', {
|
||||||
|
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function makeUser(username: string, admin: number): Promise<string> {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const id = `user-${username}`;
|
||||||
|
inst.raw.run(
|
||||||
|
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||||
|
SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`,
|
||||||
|
[id, username, admin, Date.now(), id],
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/admin/tables', () => {
|
||||||
|
it('returns 401 without a session header', async () => {
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for an expired session', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||||
|
'expired-admin-session',
|
||||||
|
'admin-kamilkrzywda',
|
||||||
|
Date.now() - 1000,
|
||||||
|
]);
|
||||||
|
const res = await get('expired-admin-session');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for a valid non-admin session', async () => {
|
||||||
|
const userId = await makeUser('tables-nonadmin', 0);
|
||||||
|
const sessionId = await createSession(await getGameDb(), userId);
|
||||||
|
const res = await get(sessionId);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/admin/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists every user table for an admin, excluding sqlite_% internals', async () => {
|
||||||
|
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
|
const res = await get(sessionId);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name);
|
||||||
|
|
||||||
|
for (const expected of ['packages', 'players', 'planes', 'sessions', 'sim_state', 'users']) {
|
||||||
|
expect(names, `missing ${expected}`).toContain(expected);
|
||||||
|
}
|
||||||
|
expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false);
|
||||||
|
// Name-ordered.
|
||||||
|
expect([...names].sort()).toEqual(names);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name
|
||||||
|
// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const guard = await requireAdmin(req);
|
||||||
|
if (!guard.ok) return guard.response;
|
||||||
|
|
||||||
|
const res = guard.inst.raw.exec(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||||
|
);
|
||||||
|
return Response.json(res[0].values.map((r) => ({ name: String(r[0]) })));
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { getPlayer } from '@/lib/db/localPlayer';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
import { GET } from './route';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
|
||||||
|
// this test never touches ./db/flight.db. getGameDb() reads the env var on first call, and
|
||||||
|
// vitest gives each test file its own module registry (fresh singleton).
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
|
||||||
|
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/events', () => {
|
||||||
|
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
|
||||||
|
// param — this test exercises exactly that path (requireAuthSse).
|
||||||
|
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
|
||||||
|
|
||||||
|
it('rejects an unauthenticated request with 401', async () => {
|
||||||
|
const res = await GET(new Request('http://localhost/api/events'));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams SSE with the current snapshot as the first event', async () => {
|
||||||
|
const res = await GET(req());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||||
|
expect(res.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect(res.headers.get('x-accel-buffering')).toBe('no');
|
||||||
|
|
||||||
|
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
try {
|
||||||
|
const { value } = await reader.read(); // first chunk: the initial snapshot event
|
||||||
|
const text = new TextDecoder().decode(value);
|
||||||
|
expect(text.startsWith('data: ')).toBe(true);
|
||||||
|
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||||
|
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
||||||
|
|
||||||
|
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
|
||||||
|
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
|
||||||
|
const planes = snap.planes as Array<Record<string, unknown>>;
|
||||||
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const key of ['ownerId', 'ownerName']) {
|
||||||
|
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
const pkgs = snap.packages as Array<Record<string, unknown>>;
|
||||||
|
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
|
||||||
|
} finally {
|
||||||
|
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a distinct economy + their own session (rows minted like /api/register).
|
||||||
|
const idB = 'test-player-events-b';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
|
||||||
|
});
|
||||||
|
const sidB = await createSession(inst, idB);
|
||||||
|
const localCash = (await getPlayer(inst, 'local')).cash;
|
||||||
|
|
||||||
|
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
|
||||||
|
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
|
||||||
|
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
|
||||||
|
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
|
||||||
|
// Reads the next `data:` event off a stream and parses its snapshot.
|
||||||
|
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||||
|
const { value } = await reader.read();
|
||||||
|
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drain both connect frames (each already carries its own account row)…
|
||||||
|
const connectA = await readFrame(readerA);
|
||||||
|
const connectB = await readFrame(readerB);
|
||||||
|
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
|
||||||
|
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
|
||||||
|
|
||||||
|
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
|
||||||
|
await tickNow();
|
||||||
|
const pushA = await readFrame(readerA);
|
||||||
|
const pushB = await readFrame(readerB);
|
||||||
|
|
||||||
|
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
|
||||||
|
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
|
||||||
|
} finally {
|
||||||
|
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
|
await readerB.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { requireAuthSse } from '@/lib/auth';
|
||||||
|
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
||||||
|
import { subscribe } from '@/lib/sse';
|
||||||
|
|
||||||
|
// ISS-129 (v0.57.0): Server-Sent Events feed of state snapshots — the current snapshot on connect,
|
||||||
|
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
|
||||||
|
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
|
||||||
|
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
|
||||||
|
// pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
|
||||||
|
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
|
||||||
|
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
|
||||||
|
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
|
||||||
|
// be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
|
||||||
|
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
|
||||||
|
// player, pushing each frame only to its group; connect + pushed frames share one projection
|
||||||
|
// (getStateSnapshot + sseFrame), so they can never drift.
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await requireAuthSse(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (heartbeat !== null) clearInterval(heartbeat);
|
||||||
|
if (unsubscribe !== null) unsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
||||||
|
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
|
||||||
|
// for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
|
||||||
|
// every later push carry THIS session's player projection — subscribe keys on auth.player.id so
|
||||||
|
// the tick broadcast only sends this client frames projected for its own account row.
|
||||||
|
getStateSnapshot(auth.player.id)
|
||||||
|
.then((snapshot) => {
|
||||||
|
unsubscribe = subscribe(auth.player.id, (text) => {
|
||||||
|
try {
|
||||||
|
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
||||||
|
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
||||||
|
controller.enqueue(encoder.encode(`data: ${text}\n\n`));
|
||||||
|
} catch {
|
||||||
|
// stream already closed — cleanup runs on cancel
|
||||||
|
}
|
||||||
|
});
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(': ping\n\n'));
|
||||||
|
} catch {
|
||||||
|
// same as above
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(sseFrame(snapshot))}\n\n`));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// already closed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup(); // client went away — drop the subscription + heartbeat
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no', // prod sits behind Traefik — don't buffer the stream
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { randomUUID, scryptSync } from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { SESSION_TTL_MS } from '@/lib/auth';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v0.71.0 — login route tests. The test user is created HERE with a hash computed in-test via
|
||||||
|
// scryptSync; the seeded admin's plaintext password must never appear in any test code, so it
|
||||||
|
// is deliberately not exercised here (the e2e suite gets an admin session via /api/test/admin-session).
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
const TEST_USER = 'unit-test-user';
|
||||||
|
const TEST_PASSWORD = 'right-horse-wrong-street-42';
|
||||||
|
const SALT_HEX = randomUUID().replace(/-/g, '').slice(0, 32); // 16-byte hex salt
|
||||||
|
const HASH = `${SALT_HEX}$${scryptSync(TEST_PASSWORD, SALT_HEX, 32).toString('hex')}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-login-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'login.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/login', { method: 'POST', body: JSON.stringify(body) }));
|
||||||
|
|
||||||
|
async function createTestUser(): Promise<void> {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// Idempotent — two tests in this file both want the user.
|
||||||
|
inst.raw.run(
|
||||||
|
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||||
|
SELECT 'user-unit-test', ?, ?, 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = ?)`,
|
||||||
|
[TEST_USER, HASH, Date.now(), TEST_USER],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/login', () => {
|
||||||
|
it('rejects malformed bodies with 400', async () => {
|
||||||
|
for (const body of [{}, { username: 'x' }, { password: 'y' }, { username: '', password: 'p' }, { bogus: 1 }]) {
|
||||||
|
const res = await post(body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for an unknown username (and still runs a scrypt)', async () => {
|
||||||
|
const res = await post({ username: 'no-such-user', password: TEST_PASSWORD });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/invalid credentials/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for a wrong password and mints no session', async () => {
|
||||||
|
await createTestUser();
|
||||||
|
const before = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0];
|
||||||
|
|
||||||
|
const res = await post({ username: TEST_USER, password: 'definitely-wrong' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
|
||||||
|
const after = Number((await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]);
|
||||||
|
expect(after).toBe(Number(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a ~7-day session on success and returns the user', async () => {
|
||||||
|
await createTestUser();
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const res = await post({ username: TEST_USER, password: TEST_PASSWORD });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
|
||||||
|
expect(body.user).toEqual({ username: TEST_USER, admin: false });
|
||||||
|
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
const row = (await getGameDb()).raw.exec('SELECT user_id, expires_at FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(row[0])).toBe('user-unit-test');
|
||||||
|
const ttl = Number(row[1]) - t0;
|
||||||
|
expect(ttl).toBeGreaterThan(SESSION_TTL_MS - 5_000); // ~7 days, minus test drift
|
||||||
|
expect(ttl).toBeLessThan(SESSION_TTL_MS + 5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login works for the seeded admin row too (hash verified without its plaintext)', async () => {
|
||||||
|
// The seeded admin's hash is a fixed string in client.ts; we can only prove the route
|
||||||
|
// ACCEPTS exactly that stored digest by checking verification against a known pair — which
|
||||||
|
// would require the plaintext. So instead: assert the seeded row is what login looks up, by
|
||||||
|
// confirming an unknown-password attempt for 'kamilkrzywda' yields 401 (row found, hash mismatch).
|
||||||
|
const res = await post({ username: 'kamilkrzywda', password: 'wrong-on-purpose' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession, verifyPassword, DUMMY_HASH_FOR_UNKNOWN_USER } from '@/lib/auth';
|
||||||
|
|
||||||
|
// v0.71.0 — POST /api/login
|
||||||
|
// body { username: string, password: string } (both required non-empty; unknown fields 400).
|
||||||
|
// Success → 200 { sessionId, user: { username, admin } }; the session row lives 7 days and is
|
||||||
|
// sent back as X-Flight-Session on later requests. Failure → 401 { error }. An unknown
|
||||||
|
// username still runs one full scrypt against a fixed dummy digest so timing stays uniform.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} 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 !== 'username' && key !== 'password') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = input.username;
|
||||||
|
const password = input.password;
|
||||||
|
if (typeof username !== 'string' || username.length === 0) {
|
||||||
|
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof password !== 'string' || password.length === 0) {
|
||||||
|
return Response.json({ error: 'password is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const res = inst.raw.exec('SELECT id, password_hash, admin FROM users WHERE username = ?', [username]);
|
||||||
|
const row = res[0]?.values?.[0];
|
||||||
|
if (!row) {
|
||||||
|
verifyPassword(password, DUMMY_HASH_FOR_UNKNOWN_USER); // uniform timing — result discarded
|
||||||
|
return Response.json({ error: 'invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!verifyPassword(password, String(row[1]))) {
|
||||||
|
return Response.json({ error: 'invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = await createSession(inst, String(row[0]));
|
||||||
|
return Response.json({
|
||||||
|
sessionId,
|
||||||
|
user: { username, admin: Number(row[2]) === 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v0.71.0 — logout route tests: deletes the session named by X-Flight-Session, idempotently.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-logout-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'logout.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (sessionId?: string) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function mintSession(): Promise<string> {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
return createSession(inst, 'admin-kamilkrzywda'); // any user id works — logout only deletes the session row
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/logout', () => {
|
||||||
|
it('returns 401 without a session header', async () => {
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the named session and is idempotent on repeat calls', async () => {
|
||||||
|
const sessionId = await mintSession();
|
||||||
|
const count = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
|
||||||
|
expect(Number(count)).toBe(1);
|
||||||
|
|
||||||
|
const first = await post(sessionId);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(await first.json()).toEqual({ ok: true });
|
||||||
|
|
||||||
|
// The row is gone…
|
||||||
|
const after = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
|
||||||
|
expect(Number(after)).toBe(0);
|
||||||
|
|
||||||
|
// …and a second logout of the same (now unknown) session is still 200.
|
||||||
|
const second = await post(sessionId);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(await second.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { SESSION_HEADER } from '@/lib/auth';
|
||||||
|
|
||||||
|
// v0.71.0 — POST /api/logout (no body). Deletes the session named by the X-Flight-Session
|
||||||
|
// header; idempotent — deleting an unknown or already-deleted session is still 200. A missing
|
||||||
|
// header has nothing to delete and gets 401 like every other unauthenticated request here.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const sessionId = req.headers.get(SESSION_HEADER);
|
||||||
|
if (!sessionId) return Response.json({ error: 'authentication required' }, { status: 401 });
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -2,18 +2,22 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// 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).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -21,7 +25,14 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
|
const post = (body: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/market/buy-plane', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/market/buy-plane', () => {
|
describe('POST /api/market/buy-plane', () => {
|
||||||
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
||||||
@@ -38,14 +49,13 @@ describe('POST /api/market/buy-plane', () => {
|
|||||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
||||||
|
|
||||||
// Cash untouched by the failed attempt.
|
// Cash untouched by the failed attempt.
|
||||||
const account = (await (await getGameDb()).db.select().from(schema.account))[0];
|
expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000);
|
||||||
expect(account.cash).toBe(20_000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
|
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
|
||||||
const { db } = await getGameDb();
|
const inst = await getGameDb();
|
||||||
// Single-row account table — top up cash so the purchase can succeed.
|
// Local player row — top up cash so the purchase can succeed.
|
||||||
await db.update(schema.account).set({ cash: 500_000 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||||
|
|
||||||
const res = await post({ model: 'atr72' });
|
const res = await post({ model: 'atr72' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = input.model;
|
const model = input.model;
|
||||||
return performAction((inst) => buyPlane(inst, model));
|
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ import path from 'node:path';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// 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).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,7 +27,14 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
|
const post = (body: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/market/sell-plane', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/market/sell-plane', () => {
|
describe('POST /api/market/sell-plane', () => {
|
||||||
it('rejects a missing planeId and unknown fields with 400', async () => {
|
it('rejects a missing planeId and unknown fields with 400', async () => {
|
||||||
@@ -48,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
|
|||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// A second, grounded plane so "flying" is the reason for rejection.
|
// A second, grounded plane so "flying" is the reason for rejection.
|
||||||
await db.insert(schema.planes).values({
|
await db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||||
@@ -59,10 +71,10 @@ describe('POST /api/market/sell-plane', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
|
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
|
||||||
const { db } = await getGameDb();
|
const inst = await getGameDb();
|
||||||
// The previous test left 'plane-extra' in the DB (now flying) — ground it.
|
// The previous test left 'plane-extra' in the DB (now flying) — ground it.
|
||||||
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
|
await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const res = await post({ planeId: 'plane-extra' });
|
const res = await post({ planeId: 'plane-extra' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction((inst) => sellPlane(inst, planeId));
|
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
|
import { GET } from './route';
|
||||||
|
|
||||||
|
// v0.71.0 — /api/me tests: resolves X-Flight-Session to its user, 401 otherwise.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-me-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'me.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = (sessionId?: string) =>
|
||||||
|
GET(
|
||||||
|
new Request('http://localhost/api/me', {
|
||||||
|
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('GET /api/me', () => {
|
||||||
|
it('returns 401 without a session header', async () => {
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for an unknown session id', async () => {
|
||||||
|
const res = await get('00000000-0000-4000-8000-000000000000');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for an expired session', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A session whose expiry is already in the past.
|
||||||
|
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||||
|
'expired-session-id',
|
||||||
|
'admin-kamilkrzywda',
|
||||||
|
Date.now() - 1000,
|
||||||
|
]);
|
||||||
|
const res = await get('expired-session-id');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the user for a live session (admin flag included)', async () => {
|
||||||
|
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
|
const res = await get(sessionId);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ username: 'kamilkrzywda', admin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports non-admin users as such', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run(
|
||||||
|
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||||
|
SELECT 'user-me-test', 'me-test-user', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-me-test')`,
|
||||||
|
[Date.now()],
|
||||||
|
);
|
||||||
|
const sessionId = await createSession(inst, 'user-me-test');
|
||||||
|
const res = await get(sessionId);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ username: 'me-test-user', admin: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { currentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
// v0.71.0 — GET /api/me (no body). Returns the user behind a valid X-Flight-Session header as
|
||||||
|
// { username, admin }, or 401 when the header is missing/unknown/expired. The client uses it to
|
||||||
|
// decide what the Admin modal shows on open.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const user = await currentUser(req);
|
||||||
|
if (!user) return Response.json({ error: 'authentication required' }, { status: 401 });
|
||||||
|
return Response.json({ username: user.username, admin: user.admin });
|
||||||
|
}
|
||||||
@@ -4,16 +4,21 @@ import path from 'node:path';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// 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).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -21,9 +26,11 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
|
const post = (id: string) =>
|
||||||
params: Promise.resolve({ id }),
|
POST(
|
||||||
});
|
new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
|
||||||
|
{ params: Promise.resolve({ id }) },
|
||||||
|
);
|
||||||
|
|
||||||
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
||||||
// the exact created/deadline times so tests can compute the penalty from the
|
// the exact created/deadline times so tests can compute the penalty from the
|
||||||
@@ -57,7 +64,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
await db.insert(schema.packages).values({
|
await db.insert(schema.packages).values({
|
||||||
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — stay open past the pre-action tick
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await post('p-open');
|
const res = await post('p-open');
|
||||||
@@ -84,8 +91,9 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
|
|
||||||
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
|
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
|
||||||
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
|
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
|
||||||
const { db } = await getGameDb();
|
const inst = await getGameDb();
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
const { db } = inst;
|
||||||
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const res = await post('p-ab');
|
const res = await post('p-ab');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -94,7 +102,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
};
|
};
|
||||||
expect(Object.keys(body).sort()).toEqual([
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
@@ -103,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
|
|||||||
// id; 409 wrong status or the plane is in flight. On success returns the full
|
// id; 409 wrong status or the plane is in flight. On success returns the full
|
||||||
// state snapshot.
|
// state snapshot.
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
return performAction((inst) => abandonPackage(inst, id));
|
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// 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).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,9 +26,14 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const post = (id: string, body: unknown) =>
|
const post = (id: string, body: unknown) =>
|
||||||
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
|
POST(
|
||||||
params: Promise.resolve({ id }),
|
new Request(`http://localhost/api/packages/${id}/load`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id }) },
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/packages/:id/load', () => {
|
describe('POST /api/packages/:id/load', () => {
|
||||||
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
||||||
@@ -45,7 +54,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
await db.insert(schema.packages).values({
|
await db.insert(schema.packages).values({
|
||||||
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await post('p-ghost-plane', { planeId: 'ghost' });
|
const res = await post('p-ghost-plane', { planeId: 'ghost' });
|
||||||
@@ -57,7 +66,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
|
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
|
||||||
await db.insert(schema.packages).values({
|
await db.insert(schema.packages).values({
|
||||||
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
|
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
|
||||||
@@ -82,7 +91,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
// A known open package originating where the starter actually sits (EDDF).
|
// A known open package originating where the starter actually sits (EDDF).
|
||||||
await db.insert(schema.packages).values({
|
await db.insert(schema.packages).values({
|
||||||
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
||||||
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await post('p-home', { planeId: 'plane-starter' });
|
const res = await post('p-home', { planeId: 'plane-starter' });
|
||||||
@@ -92,7 +101,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
};
|
};
|
||||||
@@ -102,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction((inst) => loadPackage(inst, id, planeId));
|
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// 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).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,9 +26,14 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const post = (planeId: string, body: unknown) =>
|
const post = (planeId: string, body: unknown) =>
|
||||||
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
|
POST(
|
||||||
params: Promise.resolve({ planeId }),
|
new Request(`http://localhost/api/planes/${planeId}/fly`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ planeId }) },
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/planes/:planeId/fly', () => {
|
describe('POST /api/planes/:planeId/fly', () => {
|
||||||
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
||||||
@@ -63,17 +72,17 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
it('rejects a leg that does not fit even a full tank with 409 (ISS-082 backstop)', async () => {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
|
// UNNT → LPPD ≈ 7670 km needs ~10k L — beyond the ATR's 6300 L tank, so no auto-refuel can make it flyable.
|
||||||
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
await db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const res = await post('plane-starter', { destIcao: 'LEMD' });
|
const res = await post('plane-starter', { destIcao: 'LPPD' });
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||||
|
|
||||||
// Refill for later tests.
|
// Put the starter back at the hub for later tests.
|
||||||
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
|
await db.update(schema.planes).set({ locationIcao: 'EDDF' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
||||||
@@ -84,7 +93,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
|
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
|
||||||
};
|
};
|
||||||
expect(Object.keys(body).sort()).toEqual([
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
@@ -93,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
const destIcao = input.destIcao;
|
||||||
return performAction((inst) => flyPlane(inst, planeId, destIcao));
|
return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const index = input.index as number;
|
const index = input.index as number;
|
||||||
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
|
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
const destIcao = input.destIcao;
|
||||||
return performAction((inst) => queueLeg(inst, planeId, destIcao));
|
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import * as schema from '@/lib/db/schema';
|
|
||||||
import { getGameDb } from '@/lib/db/client';
|
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
|
||||||
import { haversineKm } from '@/lib/geo';
|
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
|
||||||
import { HUB_ICAO } from '@/lib/game/seed';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-refuel-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (planeId: string, body?: unknown) =>
|
|
||||||
POST(
|
|
||||||
new Request(`http://localhost/api/planes/${planeId}/refuel`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
{ params: Promise.resolve({ planeId }) },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('POST /api/planes/:planeId/refuel', () => {
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
|
||||||
const res = await post('ghost');
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
account: { cash: number };
|
|
||||||
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
|
|
||||||
};
|
|
||||||
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
|
||||||
|
|
||||||
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes)
|
|
||||||
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
|
|
||||||
|
|
||||||
// Ground it again for later tests.
|
|
||||||
await db.update(schema.planes)
|
|
||||||
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects the fill-up with 409 when cash is below the cost', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts an explicit {mode:"full"} body exactly like no body', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor
|
|
||||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { mode: 'full' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> };
|
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown mode with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { mode: 'half' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/mode/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects forFlight without a packageId with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { mode: 'forFlight' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/packageId/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown body field with 400', async () => {
|
|
||||||
const res = await post('plane-starter', { liters: 100 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid JSON with 400', async () => {
|
|
||||||
const res = await POST(
|
|
||||||
new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }),
|
|
||||||
{ params: Promise.resolve({ planeId: 'plane-starter' }) },
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
await db.update(schema.account).set({ cash: 50_000 });
|
|
||||||
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
|
|
||||||
await db.insert(schema.packages).values({
|
|
||||||
id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
|
||||||
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
|
||||||
|
|
||||||
const startFuel = Math.round(required / 2); // partial tank so L > 0
|
|
||||||
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
account: { cash: number };
|
|
||||||
planes: Array<{ id: string; fuel: number }>;
|
|
||||||
};
|
|
||||||
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
|
|
||||||
|
|
||||||
// Clean up the test package.
|
|
||||||
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { performAction, refuelPlane } from '@/lib/game/actions';
|
|
||||||
|
|
||||||
// POST /api/planes/:planeId/refuel (v0.8.0; optional body v0.16.0) — fills a grounded
|
|
||||||
// plane at the fuel price per added liter and deducts the cost from cash. The body is
|
|
||||||
// OPTIONAL: no body, {} or {mode:'full'} fill to maxFuel (the original behavior);
|
|
||||||
// {mode:'forFlight', packageId} adds just enough for that loaded package's destination
|
|
||||||
// leg. 400 bad body (unknown field, unknown mode, forFlight without packageId); 404
|
|
||||||
// unknown plane/package id; 409 if the plane is flying, the package isn't loaded on it
|
|
||||||
// (or is expired), the leg doesn't fit a full tank, or cash is insufficient. On success
|
|
||||||
// returns the full state snapshot (fuel raised by exactly the added liters).
|
|
||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
|
||||||
const { planeId } = await ctx.params;
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const text = await req.text();
|
|
||||||
body = text ? JSON.parse(text) : {};
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
||||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const input = body as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(input)) {
|
|
||||||
if (key !== 'mode' && key !== 'packageId') {
|
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mode = input.mode ?? 'full';
|
|
||||||
if (mode !== 'full' && mode !== 'forFlight') {
|
|
||||||
return Response.json({ error: 'mode must be "full" or "forFlight"' }, { status: 400 });
|
|
||||||
}
|
|
||||||
let packageId: string | undefined;
|
|
||||||
if (input.packageId !== undefined) {
|
|
||||||
if (typeof input.packageId !== 'string' || input.packageId.length === 0) {
|
|
||||||
return Response.json({ error: 'packageId must be a non-empty string' }, { status: 400 });
|
|
||||||
}
|
|
||||||
packageId = input.packageId;
|
|
||||||
}
|
|
||||||
if (mode === 'forFlight' && !packageId) {
|
|
||||||
return Response.json({ error: 'packageId is required when mode is "forFlight"' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return performAction((inst) => refuelPlane(inst, planeId, { mode, packageId }));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { performAction, repairPlane } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
|
||||||
|
// "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
|
||||||
|
// the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
|
||||||
|
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
|
||||||
|
// On success returns the full state snapshot.
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
|
const { planeId } = await ctx.params;
|
||||||
|
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { verifyPassword } from '@/lib/auth';
|
||||||
|
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB
|
||||||
|
// singleton points at a throwaway file, and every created user is verified through verifyPassword().
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'register.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/register', { method: 'POST', body: JSON.stringify(body) }));
|
||||||
|
|
||||||
|
describe('POST /api/register', () => {
|
||||||
|
it('rejects malformed bodies and bad credentials with 400', async () => {
|
||||||
|
for (const body of [
|
||||||
|
{}, // missing both fields
|
||||||
|
{ username: 'ab', password: 'longenough' }, // too short
|
||||||
|
{ username: 'a'.repeat(21), password: 'longenough' }, // too long
|
||||||
|
{ username: 'bad user!', password: 'longenough' }, // bad charset (space + !)
|
||||||
|
{ username: 'validuser', password: 'short' }, // password < 8 chars
|
||||||
|
{ username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field
|
||||||
|
]) {
|
||||||
|
const res = await post(body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => {
|
||||||
|
const res = await post({ username: 'NewUser42', password: 'correct-horse-1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
|
||||||
|
expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased
|
||||||
|
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0];
|
||||||
|
const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0];
|
||||||
|
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
|
||||||
|
expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true);
|
||||||
|
expect(String(playerRow[1])).toBe('local');
|
||||||
|
expect(Number(playerRow[2])).toBe(STARTING_CASH);
|
||||||
|
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
|
||||||
|
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
|
||||||
|
expect(planes).toHaveLength(1);
|
||||||
|
expect(String(planes[0][2])).toBe('EDDF');
|
||||||
|
|
||||||
|
// The minted session resolves to the new user.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe(String(userRow[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when the username is taken (case-insensitively)', async () => {
|
||||||
|
await post({ username: 'taken-user', password: 'correct-horse-1' });
|
||||||
|
const res = await post({ username: 'Taken-User', password: 'different-pass-2' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — POST /api/register
|
||||||
|
// body { username: string, password: string } (both required; unknown fields 400).
|
||||||
|
// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400.
|
||||||
|
// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same
|
||||||
|
// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact
|
||||||
|
// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a
|
||||||
|
// session immediately and returns { sessionId, user } like /api/login — no second round-trip.
|
||||||
|
|
||||||
|
const USERNAME_RE = /^[a-z0-9_-]{3,20}$/;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} 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 !== 'username' && key !== 'password') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = typeof input.username === 'string' ? input.username.toLowerCase() : '';
|
||||||
|
if (!USERNAME_RE.test(username)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const password = input.password;
|
||||||
|
if (typeof password !== 'string' || password.length < 8) {
|
||||||
|
return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]);
|
||||||
|
if (taken[0]?.values?.length) {
|
||||||
|
return Response.json({ error: 'username is already taken' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// One UUID for both rows — the account and its player share an id; requireAuth still joins on username.
|
||||||
|
const id = randomUUID();
|
||||||
|
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
hashPassword(password),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id,
|
||||||
|
provider: 'local',
|
||||||
|
username,
|
||||||
|
cash: STARTING_CASH,
|
||||||
|
reputation: STARTING_REPUTATION,
|
||||||
|
});
|
||||||
|
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
|
||||||
|
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
|
||||||
|
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const starter = buildStarterPlane(
|
||||||
|
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
|
||||||
|
randomUUID(),
|
||||||
|
);
|
||||||
|
const { queue, ...starterCols } = starter;
|
||||||
|
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
|
||||||
|
await persist(inst);
|
||||||
|
|
||||||
|
const sessionId = await createSession(inst, id);
|
||||||
|
return Response.json({ sessionId, user: { username, admin: false } });
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) }));
|
|
||||||
const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text }));
|
|
||||||
|
|
||||||
describe('POST /api/sim', () => {
|
|
||||||
it('returns the current simState for an empty body (tick + no-op)', async () => {
|
|
||||||
const res = await post({});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean };
|
|
||||||
expect(typeof body.clockMs).toBe('number');
|
|
||||||
expect(body.timeScale).toBe(1); // seeded default
|
|
||||||
expect(body.paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets and clamps timeScale into [1, 1000]', async () => {
|
|
||||||
const hi = await post({ timeScale: 2000 });
|
|
||||||
expect(hi.status).toBe(200);
|
|
||||||
expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000);
|
|
||||||
|
|
||||||
const lo = await post({ timeScale: 0.1 });
|
|
||||||
expect(lo.status).toBe(200);
|
|
||||||
expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1);
|
|
||||||
|
|
||||||
// The top of the ladder passes through unclamped (v0.14.0).
|
|
||||||
const max = await post({ timeScale: 1000 });
|
|
||||||
expect(max.status).toBe(200);
|
|
||||||
expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000);
|
|
||||||
|
|
||||||
// In-range values pass through unclamped — ladder and off-ladder alike.
|
|
||||||
const ladder = await post({ timeScale: 500 });
|
|
||||||
expect(ladder.status).toBe(200);
|
|
||||||
expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500);
|
|
||||||
|
|
||||||
const mid = await post({ timeScale: 37 });
|
|
||||||
expect(mid.status).toBe(200);
|
|
||||||
expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37);
|
|
||||||
|
|
||||||
// Restore the default for later tests.
|
|
||||||
await post({ timeScale: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles paused and freezes the clock across ticks', async () => {
|
|
||||||
const pause = await post({ paused: true });
|
|
||||||
expect(pause.status).toBe(200);
|
|
||||||
expect(((await pause.json()) as { paused: boolean }).paused).toBe(true);
|
|
||||||
|
|
||||||
// Two reads while paused → identical clock (tick-on-read advances nothing).
|
|
||||||
const a = await post({});
|
|
||||||
const b = await post({});
|
|
||||||
expect(((await a.json()) as { clockMs: number }).clockMs).toBe(
|
|
||||||
((await b.json()) as { clockMs: number }).clockMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resume = await post({ paused: false });
|
|
||||||
expect(resume.status).toBe(200);
|
|
||||||
expect(((await resume.json()) as { paused: boolean }).paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies both fields in one call', async () => {
|
|
||||||
const res = await post({ timeScale: 64, paused: true });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { timeScale: number; paused: boolean };
|
|
||||||
expect(body.timeScale).toBe(64);
|
|
||||||
expect(body.paused).toBe(true);
|
|
||||||
await post({ timeScale: 1, paused: false }); // restore
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid input with 400', async () => {
|
|
||||||
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
|
||||||
const res = await post(body);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const text of ['nope{', '[1,2]', '"str"', 'null']) {
|
|
||||||
const res = await postRaw(text);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { applyTimeControls } from '@/lib/sim/engine';
|
|
||||||
|
|
||||||
// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }.
|
|
||||||
// Ticks the clock first (so the response reflects current game time), then applies
|
|
||||||
// and persists the new controls. Returns the updated simState.
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const text = await req.text();
|
|
||||||
body = text ? JSON.parse(text) : {};
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
||||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = body as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(input)) {
|
|
||||||
if (key !== 'timeScale' && key !== 'paused') {
|
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) {
|
|
||||||
return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (input.paused !== undefined && typeof input.paused !== 'boolean') {
|
|
||||||
return Response.json({ error: 'paused must be a boolean' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sim = await applyTimeControls({
|
|
||||||
timeScale: input.timeScale as number | undefined,
|
|
||||||
paused: input.paused as boolean | undefined,
|
|
||||||
});
|
|
||||||
return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to apply sim controls', err);
|
|
||||||
return Response.json({ error: 'failed to apply sim controls' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+101
-11
@@ -2,10 +2,17 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import { GET } from './route';
|
import { GET } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Point the app's DB singleton at a throwaway file so this test never
|
// Point the app's DB singleton at a throwaway file so this test never
|
||||||
@@ -13,8 +20,14 @@ beforeAll(async () => {
|
|||||||
// vitest gives each test file its own module registry (fresh singleton).
|
// vitest gives each test file its own module registry (fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
||||||
|
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
|
||||||
|
// (no password needed: createSession only inserts into sessions; the guard joins players on
|
||||||
|
// username → the 'local' row).
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
@@ -22,7 +35,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe('GET /api/state', () => {
|
describe('GET /api/state', () => {
|
||||||
it('returns 200 with the full snapshot shape', async () => {
|
it('returns 200 with the full snapshot shape', async () => {
|
||||||
const res = await GET();
|
const res = await GET(req());
|
||||||
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');
|
||||||
|
|
||||||
@@ -33,6 +46,7 @@ describe('GET /api/state', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
@@ -41,15 +55,13 @@ describe('GET /api/state', () => {
|
|||||||
expect(typeof account.cash).toBe('number');
|
expect(typeof account.cash).toBe('number');
|
||||||
expect(typeof account.reputation).toBe('number');
|
expect(typeof account.reputation).toBe('number');
|
||||||
|
|
||||||
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean };
|
const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
|
||||||
expect(typeof sim.clockMs).toBe('number');
|
expect(typeof sim.clockMs).toBe('number');
|
||||||
expect(typeof sim.timeScale).toBe('number');
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
|
|
||||||
const planes = body.planes as Array<Record<string, unknown>>;
|
const planes = body.planes as Array<Record<string, unknown>>;
|
||||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
||||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
||||||
}
|
}
|
||||||
expect(typeof planes[0].locationName).toBe('string');
|
expect(typeof planes[0].locationName).toBe('string');
|
||||||
// The seeded starter is grounded — no flight destination yet.
|
// The seeded starter is grounded — no flight destination yet.
|
||||||
@@ -58,19 +70,30 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const packages = body.packages as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(packages.length).toBeGreaterThan(0);
|
expect(packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
||||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||||
|
// fixture airports' coordinates for every package in the snapshot.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const from = coords.get(String(pkg.originIcao));
|
||||||
|
const to = coords.get(String(pkg.destIcao));
|
||||||
|
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
|
||||||
|
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||||
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||||
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||||
expect((await b).account.cash).toBe((await a).account.cash);
|
expect((await b).account.cash).toBe((await a).account.cash);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
|
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
|
||||||
const body = ((await GET()).json()) as Promise<{
|
const body = ((await GET(req())).json()) as Promise<{
|
||||||
catalog: Array<Record<string, unknown>>;
|
catalog: Array<Record<string, unknown>>;
|
||||||
fuelPricePerL: number;
|
fuelPricePerL: number;
|
||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
@@ -93,4 +116,71 @@ describe('GET /api/state', () => {
|
|||||||
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
||||||
expect(snap.sellFraction).toBe(SELL_FRACTION);
|
expect(snap.sellFraction).toBe(SELL_FRACTION);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second account with a distinct economy — rows minted like /api/register does.
|
||||||
|
const id = 'test-player-b';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
|
||||||
|
});
|
||||||
|
const sid = await createSession(inst, id);
|
||||||
|
|
||||||
|
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { account: { cash: number; reputation: number } };
|
||||||
|
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
|
||||||
|
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a plane of their own — rows minted like /api/register does.
|
||||||
|
const id = 'test-player-c';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
|
||||||
|
});
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
|
||||||
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
|
||||||
|
const starter = body.planes.find((p) => p.id === 'plane-starter');
|
||||||
|
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
|
||||||
|
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
|
||||||
|
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
|
||||||
|
expect(foreign?.ownerId).toBe(id);
|
||||||
|
expect(foreign?.ownerName).toBe('e2e-owner-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
|
||||||
|
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const snapBefore = await before;
|
||||||
|
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
|
||||||
|
|
||||||
|
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
|
||||||
|
await inst.db.update(schema.packages)
|
||||||
|
.set({ status: 'loaded', planeId: 'plane-test-c' })
|
||||||
|
.where(eq(schema.packages.id, String(openPkg.id)));
|
||||||
|
|
||||||
|
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const after = await snap;
|
||||||
|
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
|
||||||
|
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
|
||||||
|
for (const pkg of after.packages) { // …and every unassigned package stays null.
|
||||||
|
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
export async function GET() {
|
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
|
||||||
|
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
|
||||||
|
// v1.4.0 (ISS-112): full shared-world snapshot — ALL planes with owner info, packages with
|
||||||
|
// carrierName; same projection as the SSE frames, so poll and push can never drift.
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await requireAuth(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
try {
|
try {
|
||||||
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
|
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
|
||||||
// read reflects current game time (expiry/finalize/spawn run inside the tick).
|
// read reflects current game time (expiry/finalize/spawn run inside the tick).
|
||||||
await tickNow();
|
await tickNow();
|
||||||
return Response.json(await getStateSnapshot());
|
return Response.json(await getStateSnapshot(auth.player.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load game state', err);
|
console.error('failed to load game state', err);
|
||||||
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.31.0, ISS-055) — grants cash so the e2e suite can afford a second
|
||||||
|
// plane (ATR €200k vs the €20k starting balance). Disabled unless FLIGHT_TEST_HOOKS=1
|
||||||
|
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
|
||||||
|
// Sibling of /api/test/create-package — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/add-cash
|
||||||
|
// body { amount?: number } — a positive finite number when provided; defaults to €1M.
|
||||||
|
// Adds to account.cash (rounded to cents) and returns the full state snapshot.
|
||||||
|
|
||||||
|
const DEFAULT_AMOUNT = 1_000_000;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'amount') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let amount = DEFAULT_AMOUNT;
|
||||||
|
if (input.amount !== undefined) {
|
||||||
|
if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) {
|
||||||
|
return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
amount = input.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
|
||||||
|
// player the e2e suite plays via mint-player-session's default).
|
||||||
|
const player = await getLocalPlayer(inst);
|
||||||
|
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.71.0) — mints a live session for the SEEDED admin user server-side and
|
||||||
|
// returns its id, so the e2e suite can exercise the authenticated Admin panel WITHOUT the real
|
||||||
|
// admin password ever appearing in test code (the spec forbids it). No body. Disabled unless
|
||||||
|
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.ci.yml for the CI stack only); every other
|
||||||
|
// deployment 404s here, like its /api/test/* siblings.
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const res = inst.raw.exec("SELECT id FROM users WHERE username = 'kamilkrzywda'");
|
||||||
|
const userId = res[0]?.values?.[0]?.[0];
|
||||||
|
if (typeof userId !== 'string') {
|
||||||
|
return Response.json({ error: 'seeded admin user missing' }, { status: 500 });
|
||||||
|
}
|
||||||
|
const sessionId = await createSession(inst, userId);
|
||||||
|
return Response.json({ sessionId });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { getSharedWorldReputation } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||||
@@ -21,6 +22,9 @@ import { tickNow } from '@/lib/sim/engine';
|
|||||||
// hook deadline of expire-package) — pass an explicit value to override.
|
// hook deadline of expire-package) — pass an explicit value to override.
|
||||||
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
|
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
|
||||||
// flows that load several packages can stay under the plane's payload deterministically.
|
// flows that load several packages can stay under the plane's payload deterministically.
|
||||||
|
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
|
||||||
|
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
|
||||||
|
// e2e specs that assert exact reward/rep values stay deterministic.
|
||||||
// Returns 201 + the created package row.
|
// Returns 201 + the created package row.
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -40,7 +44,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const input = body as Record<string, unknown>;
|
const input = body as Record<string, unknown>;
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
|
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,9 @@ export async function POST(req: Request) {
|
|||||||
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
|
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
|
||||||
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
|
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
|
||||||
|
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
@@ -76,6 +83,9 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
|
// v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
|
||||||
|
// rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
|
||||||
|
const reputation = await getSharedWorldReputation(inst);
|
||||||
const created = generatePackage({
|
const created = generatePackage({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
airports,
|
airports,
|
||||||
@@ -85,6 +95,9 @@ export async function POST(req: Request) {
|
|||||||
originIcao: input.originIcao as string | undefined,
|
originIcao: input.originIcao as string | undefined,
|
||||||
destIcao: input.destIcao as string | undefined,
|
destIcao: input.destIcao as string | undefined,
|
||||||
weightKg: input.weightKg as number | undefined,
|
weightKg: input.weightKg as number | undefined,
|
||||||
|
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||||||
|
special: input.special === undefined ? null : (input.special as string | null),
|
||||||
|
reputation,
|
||||||
});
|
});
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
|
||||||
|
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
|
||||||
|
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
|
||||||
|
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
|
||||||
|
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
|
||||||
|
// same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/delete-plane
|
||||||
|
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
|
||||||
|
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
|
||||||
|
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
|
||||||
|
|
||||||
|
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 !== 'username') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = input.username;
|
||||||
|
if (typeof username !== 'string' || username.length === 0) {
|
||||||
|
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
|
||||||
|
let deleted = 0;
|
||||||
|
if (players[0]) {
|
||||||
|
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
|
||||||
|
for (const plane of owned) {
|
||||||
|
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
|
||||||
|
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
|
||||||
|
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
await persist(inst);
|
||||||
|
}
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,21 +3,22 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
import { generatePackage, HUB_ICAO, JOB_LIFETIME_MS, mulberry32 } from '@/lib/game/seed';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite
|
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0; reworked for wall-clock expiry in v0.65.0) —
|
||||||
// observe package expiry without waiting hours of game time. Disabled unless
|
// lets the e2e suite observe open-job expiry without waiting an hour of real time. Disabled unless
|
||||||
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance
|
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance only; every other
|
||||||
// only; every other deployment 404s here.
|
// deployment 404s here.
|
||||||
//
|
//
|
||||||
// POST /api/test/expire-package
|
// POST /api/test/expire-package
|
||||||
// body { packageId?: string }
|
// body { packageId?: string }
|
||||||
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS
|
// - with packageId: ages that OPEN package's createdRealMs to HOOK_AGE_MS in the past so the next
|
||||||
// (game time) so the next fast tick expires it; 404 if unknown/not open.
|
// tick (run immediately via tickNow) flips it to 'expired'; 404 if unknown/not open.
|
||||||
// - without: creates a fresh open package with the same short deadline.
|
// - without: creates a fresh open package at the home hub, already aged past its lifetime — visible
|
||||||
|
// while open (the hub is always in the pool), expired by the time the caller polls /api/state.
|
||||||
|
|
||||||
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (≈ 0.01 s real at the fastest 1000×)
|
const HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000; // comfortably past the job lifetime
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
@@ -41,8 +42,6 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
const sim = await tickNow(); // clock is current before we set the deadline
|
|
||||||
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
|
|
||||||
|
|
||||||
if (typeof packageId === 'string') {
|
if (typeof packageId === 'string') {
|
||||||
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
@@ -50,8 +49,11 @@ export async function POST(req: Request) {
|
|||||||
if (!row || row.status !== 'open') {
|
if (!row || row.status !== 'open') {
|
||||||
return Response.json({ error: 'package not found or not open' }, { status: 404 });
|
return Response.json({ error: 'package not found or not open' }, { status: 404 });
|
||||||
}
|
}
|
||||||
await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId));
|
await inst.db.update(schema.packages).set({ createdRealMs: Date.now() - HOOK_AGE_MS }).where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
// AFTER the mutation on purpose: tickNow runs a full tickOnce, so expireOpenPackages flips the
|
||||||
|
// aged row to 'expired' within this same call — callers can poll /api/state immediately.
|
||||||
|
await tickNow();
|
||||||
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
|
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
|
||||||
return Response.json(updated ?? row);
|
return Response.json(updated ?? row);
|
||||||
}
|
}
|
||||||
@@ -64,10 +66,18 @@ export async function POST(req: Request) {
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
|
// nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs
|
||||||
|
// is real time. special: null — the hook never rolls a special status (v0.37.0). Origin forced to
|
||||||
|
// the home hub so the job is visible while open; already aged past its lifetime, it expires on
|
||||||
|
// the tick below.
|
||||||
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, originIcao: HUB_ICAO, special: null, createdRealMs: Date.now() - HOOK_AGE_MS });
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(created, { status: 201 });
|
await tickNow();
|
||||||
|
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, created.id)))[0];
|
||||||
|
return Response.json(updated ?? created, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('test hook failed', err);
|
console.error('test hook failed', err);
|
||||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set
|
||||||
|
// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body?: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/test/mint-player-session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('POST /api/test/mint-player-session', () => {
|
||||||
|
it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '');
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string };
|
||||||
|
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// The session resolves to the EXISTING admin user — no new users/players rows are created.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe('admin-kamilkrzywda');
|
||||||
|
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
|
||||||
|
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const res = await post({ username: 'e2e-tester' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string };
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
|
||||||
|
expect(userRow.length).toBe(1);
|
||||||
|
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
|
||||||
|
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
|
||||||
|
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
|
||||||
|
expect(planes).toHaveLength(1);
|
||||||
|
expect(String(planes[0][2])).toBe('EDDF');
|
||||||
|
|
||||||
|
// The minted session resolves to the new user.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe(String(userRow[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
|
||||||
|
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
|
||||||
|
|
||||||
|
const res = await post({ username: 'e2e-tester' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
|
||||||
|
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bad usernames and unknown fields with 400', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) {
|
||||||
|
const res = await post(body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
|
||||||
|
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
|
||||||
|
// /api/test/admin-session). Creates the users + players rows if missing — idempotent across
|
||||||
|
// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every
|
||||||
|
// other deployment 404s here, like its /api/test/* siblings.
|
||||||
|
//
|
||||||
|
// POST /api/test/mint-player-session
|
||||||
|
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
|
||||||
|
// with the seeded world's planes by id, so the minted session must resolve to the local player
|
||||||
|
// that owns them after the owner_id backfill); a custom username creates a fresh test account
|
||||||
|
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
|
||||||
|
// Returns { sessionId }.
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = 'kamilkrzywda';
|
||||||
|
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
|
||||||
|
// server-side); a fixed value keeps the row debuggable from the admin panel.
|
||||||
|
const TEST_PASSWORD = 'e2e-mint-password-42';
|
||||||
|
|
||||||
|
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 !== 'username') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let username = DEFAULT_USERNAME;
|
||||||
|
if (input.username !== undefined) {
|
||||||
|
if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
username = input.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]);
|
||||||
|
let userId = res[0]?.values?.[0]?.[0];
|
||||||
|
if (typeof userId !== 'string') {
|
||||||
|
// New test account — one UUID for both rows, starter economy like a fresh world.
|
||||||
|
const id = randomUUID();
|
||||||
|
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
hashPassword(TEST_PASSWORD),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id,
|
||||||
|
provider: 'local',
|
||||||
|
username,
|
||||||
|
cash: STARTING_CASH,
|
||||||
|
reputation: STARTING_REPUTATION,
|
||||||
|
});
|
||||||
|
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const starter = buildStarterPlane(
|
||||||
|
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
|
||||||
|
randomUUID(),
|
||||||
|
);
|
||||||
|
const { queue, ...starterCols } = starter;
|
||||||
|
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
|
||||||
|
userId = id;
|
||||||
|
}
|
||||||
|
const sessionId = await createSession(inst, userId); // persists the rows + session together
|
||||||
|
return Response.json({ sessionId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
|
||||||
|
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
|
||||||
|
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
|
||||||
|
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
|
||||||
|
// stays for relative grants, this one is exact.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-cash
|
||||||
|
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
|
||||||
|
// account.cash to exactly that value and returns the full state snapshot after ticking.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'amount') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const amount = input.amount;
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||||
|
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
|
||||||
|
// the e2e suite plays via mint-player-session's default).
|
||||||
|
await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
|
||||||
|
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
|
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);
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
|
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);
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { setTestTimeScale } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now
|
||||||
|
// that there are no player-facing controls; without it every spec would run at the fixed world
|
||||||
|
// rate and a cross-Europe leg would take hours of real time. Disabled unless FLIGHT_TEST_HOOKS=1
|
||||||
|
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
|
||||||
|
// Sibling of /api/test/set-health — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-timescale
|
||||||
|
// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise).
|
||||||
|
// Ticks first so the write lands on current game state, under the sim lock.
|
||||||
|
// Returns { clockMs, timeScale } — the new effective scale for test assertions.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'timeScale') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timeScale = input.timeScale;
|
||||||
|
if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) {
|
||||||
|
return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await setTestTimeScale(timeScale);
|
||||||
|
return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
-12
@@ -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,35 @@ body {
|
|||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
|
||||||
|
.status-broken {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-repairing {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
|
||||||
|
.special-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(56, 189, 248, 0.2);
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v1.4.0 (ISS-112): owner / carrier badge — whose plane or cargo this is in the shared world. */
|
||||||
|
.owner-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
.fuel-bar {
|
.fuel-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -228,6 +267,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;
|
||||||
@@ -376,17 +435,10 @@ body {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
|
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
|
||||||
.onboard-block {
|
a full-width fly-to button then refuel + abandon side by side below it; long destination names
|
||||||
background: rgba(30, 41, 59, 0.6);
|
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
|
||||||
border-radius: 6px;
|
.plane-row .contract-row {
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
|
|
||||||
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
|
|
||||||
.onboard-block .contract-row {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
@@ -395,7 +447,7 @@ body {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboard-block .contract-row .contract-sub {
|
.plane-row .contract-row .contract-sub {
|
||||||
margin-top: 0; /* row spacing comes from the container's gap */
|
margin-top: 0; /* row spacing comes from the container's gap */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,3 +597,239 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.71.0 — Admin modal: overlay sits above the z-50 toast stack; panel matches the dark slate theme. */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60; /* above toasts (z-50) and everything else on screen */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: min(920px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(15, 23, 42, 0.97);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 12px 40px rgba(2, 6, 23, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.71.0 — Admin panel content (login form + generic DB grid). */
|
||||||
|
.admin-panel {
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel input[type='text'],
|
||||||
|
.admin-panel input:not([type]),
|
||||||
|
.admin-login input {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rows-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rows {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rows th,
|
||||||
|
.admin-rows td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rows th {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell {
|
||||||
|
width: 110px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
|
||||||
|
.leaderboard {
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th,
|
||||||
|
.leaderboard td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
|
||||||
|
.sign-in {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.97);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 300px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in input {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in .pane-btn {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-divider {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#93c5fd"/>
|
||||||
|
<path d="M32 5 L36 20 L59 33 L36 38 L35 47 L48 56 L35 52 L32 59 L29 52 L16 56 L29 47 L28 38 L5 33 L28 20 Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 248 B |
+58
-4
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import LeftPane from '@/components/LeftPane';
|
import LeftPane from '@/components/LeftPane';
|
||||||
import RightPane from '@/components/RightPane';
|
import RightPane from '@/components/RightPane';
|
||||||
|
import SignInScreen from '@/components/SignInScreen';
|
||||||
import ToastStack from '@/components/Toasts';
|
import ToastStack from '@/components/Toasts';
|
||||||
import type { HoverTarget } from '@/components/hoverTypes';
|
import type { HoverTarget } from '@/components/hoverTypes';
|
||||||
|
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
|
||||||
import { useGameState } from '@/lib/useGameState';
|
import { useGameState } from '@/lib/useGameState';
|
||||||
import { useToasts } from '@/lib/useToasts';
|
import { useToasts } from '@/lib/useToasts';
|
||||||
import { diffSnapshots } from '@/lib/deriveEvents';
|
import { diffSnapshots } from '@/lib/deriveEvents';
|
||||||
@@ -13,10 +15,62 @@ import { diffSnapshots } from '@/lib/deriveEvents';
|
|||||||
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
// v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either
|
||||||
|
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
||||||
|
// feed never polls an unauthenticated /api/state.
|
||||||
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
// v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map.
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
// Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must
|
||||||
|
// track whoever just signed in, not the user probed on mount (or before the last logout).
|
||||||
|
const [probeKey, setProbeKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = storedSession();
|
||||||
|
if (!sid) {
|
||||||
|
setAuthed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
||||||
|
else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158)
|
||||||
|
setAuthed(res.ok);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [probeKey]);
|
||||||
|
|
||||||
|
if (authed === null) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<p data-testid="auth-checking" className="sign-in-loading">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!authed) {
|
||||||
|
// v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed —
|
||||||
|
// isAdmin has to come from the user who just signed in.
|
||||||
|
return <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||||
|
return <AppShell onLogout={() => setAuthed(false)} isAdmin={isAdmin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) {
|
||||||
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
||||||
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
||||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||||
const { state, loading, error, refresh, applySnapshot } = useGameState(1000);
|
const { state, loading, error, applySnapshot } = useGameState(1000);
|
||||||
|
|
||||||
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
|
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
|
||||||
// either pane highlights the matching plane/route/airports on the map.
|
// either pane highlights the matching plane/route/airports on the map.
|
||||||
@@ -44,12 +98,12 @@ 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} 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
|
||||||
action response into the shared state, exactly like RightPane does */}
|
action response into the shared state, exactly like RightPane does */}
|
||||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
||||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
|
||||||
|
|
||||||
|
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
|
||||||
|
// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD
|
||||||
|
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
username: string;
|
||||||
|
admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowsData {
|
||||||
|
columns: string[];
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
|
||||||
|
const [session, setSession] = useState<string | null>(null);
|
||||||
|
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
// Login form state.
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Admin grid state.
|
||||||
|
const [tables, setTables] = useState<string[]>([]);
|
||||||
|
const [table, setTable] = useState<string | null>(null);
|
||||||
|
const [rowsData, setRowsData] = useState<RowsData | null>(null);
|
||||||
|
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
|
||||||
|
const [newRow, setNewRow] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
|
||||||
|
|
||||||
|
// On mount: a stored session decides between the grid and the login form.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const stored = storedSession();
|
||||||
|
if (!stored) {
|
||||||
|
setChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/me', { headers: authHeaders(stored) })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok || cancelled) {
|
||||||
|
clearStoredSession();
|
||||||
|
setSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as Me;
|
||||||
|
setSession(stored);
|
||||||
|
setMe(body);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
clearStoredSession();
|
||||||
|
setSession(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setChecking(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [authHeaders]);
|
||||||
|
|
||||||
|
const loadTables = useCallback(async (s: string) => {
|
||||||
|
const res = await fetch('/api/admin/tables', { headers: authHeaders(s) });
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name));
|
||||||
|
}, [authHeaders]);
|
||||||
|
|
||||||
|
const loadRows = useCallback(
|
||||||
|
async (s: string, name: string) => {
|
||||||
|
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) });
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
setRowsData(await res.json());
|
||||||
|
},
|
||||||
|
[authHeaders],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter the grid once /api/me confirms an admin.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || !me?.admin) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await loadTables(session);
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [session, me, loadTables]);
|
||||||
|
|
||||||
|
const openTable = async (name: string) => {
|
||||||
|
if (!session) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await loadRows(session, name);
|
||||||
|
setTable(name);
|
||||||
|
setEdits({});
|
||||||
|
setNewRow(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!session || !table) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await loadRows(session, table);
|
||||||
|
setEdits({});
|
||||||
|
setNewRow(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!username || !password) return;
|
||||||
|
setLoginError(null);
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { sessionId: string; user: Me };
|
||||||
|
storeSession(body.sessionId);
|
||||||
|
setSession(body.sessionId);
|
||||||
|
setMe(body.user);
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
|
||||||
|
clearStoredSession();
|
||||||
|
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
|
||||||
|
setSession(null);
|
||||||
|
setMe(null);
|
||||||
|
setTable(null);
|
||||||
|
setRowsData(null);
|
||||||
|
setTables([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checking) return <div className="admin-panel">loading…</div>;
|
||||||
|
|
||||||
|
// No valid session → login form.
|
||||||
|
if (!session || !me) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="admin-panel admin-login"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void login();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
data-testid="admin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{loginError && (
|
||||||
|
<div data-testid="admin-error" className="admin-error">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated but not an admin.
|
||||||
|
if (!me.admin) {
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<p data-testid="admin-not-admin" className="admin-message">
|
||||||
|
Logged in as <strong>{me.username}</strong> — this account is not an admin.
|
||||||
|
</p>
|
||||||
|
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = rowsData?.columns ?? [];
|
||||||
|
// Displayed grid: server rows plus one trailing empty row when "+ Add row" is active.
|
||||||
|
const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0);
|
||||||
|
const cellValue = (rowIdx: number, col: string): string => {
|
||||||
|
if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? '');
|
||||||
|
const key = `${rowIdx}:${col}`;
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
const raw = rowsData?.rows[rowIdx]?.[col];
|
||||||
|
return raw === null || raw === undefined ? '' : String(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRow = async (rowIdx: number) => {
|
||||||
|
if (!session || !table || !rowsData) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (newRow && rowIdx === rowsData.rows.length) {
|
||||||
|
// New row → POST the non-empty cells.
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col === '__rowid') continue;
|
||||||
|
const v = cellValue(rowIdx, col);
|
||||||
|
if (v !== '') values[col] = v;
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
} else {
|
||||||
|
// Existing row → PUT only the changed cells (cleared cell = NULL).
|
||||||
|
const values: Record<string, string | null> = {};
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col === '__rowid') continue;
|
||||||
|
const original = rowsData.rows[rowIdx]?.[col];
|
||||||
|
const origStr = original === null || original === undefined ? '' : String(original);
|
||||||
|
const v = cellValue(rowIdx, col);
|
||||||
|
if (v !== origStr) values[col] = v === '' ? null : v;
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length === 0) return; // nothing changed
|
||||||
|
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||||
|
body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
await loadRows(session, table); // re-fetch so the grid shows server state (persistence check)
|
||||||
|
setEdits({});
|
||||||
|
setNewRow(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRow = async (rowIdx: number) => {
|
||||||
|
if (!session || !table || !rowsData?.rows[rowIdx]) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const rowid = rowsData.rows[rowIdx]?.__rowid;
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`,
|
||||||
|
{ method: 'DELETE', headers: authHeaders(session) },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||||
|
await loadRows(session, table);
|
||||||
|
setEdits({});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<span data-testid="admin-user" className="admin-message">
|
||||||
|
{me.username} (admin)
|
||||||
|
</span>
|
||||||
|
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!table && (
|
||||||
|
<>
|
||||||
|
<p className="admin-message">Pick a table:</p>
|
||||||
|
<div data-testid="admin-tables" className="admin-table-list">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{table && rowsData && (
|
||||||
|
<>
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<strong data-testid="admin-current-table">{table}</strong>
|
||||||
|
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
|
||||||
|
← tables
|
||||||
|
</button>
|
||||||
|
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="admin-add-row"
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={busy || newRow}
|
||||||
|
onClick={() => setNewRow(true)}
|
||||||
|
>
|
||||||
|
+ Add row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-rows-wrap">
|
||||||
|
<table data-testid="admin-rows" className="admin-rows">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c}>{c}</th>
|
||||||
|
))}
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rowCount }, (_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<td key={c}>
|
||||||
|
<input
|
||||||
|
data-testid={`admin-cell-${i}-${c}`}
|
||||||
|
className="admin-cell"
|
||||||
|
value={cellValue(i, c)}
|
||||||
|
disabled={busy || c === '__rowid'}
|
||||||
|
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="admin-row-actions">
|
||||||
|
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
|
||||||
|
Save
|
||||||
|
</button>{' '}
|
||||||
|
{!(newRow && i === rowsData.rows.length) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`admin-delete-${i}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void deleteRow(i)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div data-testid="admin-error" className="admin-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { formatCash } from '@/lib/format';
|
||||||
|
import type { PlayerSummary } from '@/lib/game/state';
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
|
||||||
|
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
|
||||||
|
// reputation desc; cash then username break ties deterministically so rows don't shuffle
|
||||||
|
// between snapshots when two players share a reputation.
|
||||||
|
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
|
||||||
|
const sorted = [...players].sort(
|
||||||
|
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="leaderboard">
|
||||||
|
<table data-testid="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Cash</th>
|
||||||
|
<th>Reputation</th>
|
||||||
|
<th>Planes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p) => (
|
||||||
|
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
|
||||||
|
<td>{p.username}</td>
|
||||||
|
<td>{formatCash(p.cash)}</td>
|
||||||
|
<td>{p.reputation}</td>
|
||||||
|
<td>{p.planeCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+211
-474
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
import { cargoWeightFor } from './RightPane';
|
import { cargoWeightFor } from './RightPane';
|
||||||
@@ -16,10 +15,8 @@ interface LeftPaneProps {
|
|||||||
state: StateSnapshot | null;
|
state: StateSnapshot | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
|
||||||
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 On board section's 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;
|
||||||
@@ -27,12 +24,6 @@ interface LeftPaneProps {
|
|||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
|
|
||||||
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
|
|
||||||
// roughly doubles the speed.
|
|
||||||
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
|
|
||||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
|
||||||
|
|
||||||
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
||||||
function planeLocation(p: PlaneState): string {
|
function planeLocation(p: PlaneState): string {
|
||||||
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
||||||
@@ -47,52 +38,8 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
|||||||
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||||
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
|
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||||
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
|
||||||
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
|
|
||||||
return pendingScale !== null && serverTimeScale === pendingScale;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
|
|
||||||
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
|
|
||||||
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
|
|
||||||
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
|
|
||||||
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
|
|
||||||
export const SIM_POST_MAX_ATTEMPTS = 3;
|
|
||||||
export const SIM_POST_RETRY_DELAY_MS = 500;
|
|
||||||
|
|
||||||
export type SimPostRetryResult =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; reason: 'exhausted'; error: unknown }
|
|
||||||
| { ok: false; reason: 'superseded' };
|
|
||||||
|
|
||||||
export async function retrySimPost(
|
|
||||||
post: () => Promise<void>,
|
|
||||||
superseded: () => boolean,
|
|
||||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
||||||
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
|
|
||||||
delayMs: number = SIM_POST_RETRY_DELAY_MS,
|
|
||||||
): Promise<SimPostRetryResult> {
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
|
|
||||||
try {
|
|
||||||
await post();
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
|
|
||||||
if (attempt < maxAttempts) await sleep(delayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: false, reason: 'exhausted', error: lastError };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
|
|
||||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
||||||
@@ -101,15 +48,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-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
|
|
||||||
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
|
|
||||||
const [queueAddDestIcao, setQueueAddDestIcao] = 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
|
||||||
@@ -132,163 +70,23 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
|
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||||
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
|
|
||||||
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
|
|
||||||
// toast (the running poll retries on its own). A commit that lands while another POST is in
|
|
||||||
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
|
|
||||||
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
|
|
||||||
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
|
|
||||||
// which used to let a flush re-queue instead of send and silently drop a commit.
|
|
||||||
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
|
|
||||||
const busyRef = useRef(false);
|
|
||||||
|
|
||||||
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
|
|
||||||
|
|
||||||
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
|
|
||||||
// 'queued': an in-flight call holds it — its flush will send it.
|
|
||||||
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
|
|
||||||
const applySimControls = useCallback(
|
|
||||||
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
|
|
||||||
if (busyRef.current) {
|
|
||||||
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
|
|
||||||
return 'queued';
|
|
||||||
}
|
|
||||||
busyRef.current = true;
|
|
||||||
setBusy(true);
|
|
||||||
let sentOwn = false;
|
|
||||||
try {
|
|
||||||
let current: { timeScale?: number; paused?: boolean } | null = body;
|
|
||||||
while (current !== null) {
|
|
||||||
const target = current; // stable across this retry round
|
|
||||||
const result = await retrySimPost(
|
|
||||||
async () => {
|
|
||||||
const res = await fetch('/api/sim', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(target),
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
|
||||||
},
|
|
||||||
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
|
|
||||||
);
|
|
||||||
if (result.ok) {
|
|
||||||
onRefresh();
|
|
||||||
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
|
|
||||||
} else if (result.reason === 'exhausted') {
|
|
||||||
throw result.error; // only after the final attempt fails → error toast below
|
|
||||||
}
|
|
||||||
current = queuedRef.current; // pick up anything that arrived while we were in flight
|
|
||||||
queuedRef.current = null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to apply sim controls', err);
|
|
||||||
// ISS-008: surface the failure as an error toast too — previously console-only.
|
|
||||||
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
|
|
||||||
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
|
|
||||||
} finally {
|
|
||||||
busyRef.current = false;
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
return sentOwn ? 'sent' : 'failed';
|
|
||||||
},
|
|
||||||
[onRefresh, onError],
|
|
||||||
);
|
|
||||||
|
|
||||||
const paused = state?.simState.paused ?? false;
|
|
||||||
const timeScale = state?.simState.timeScale ?? 1;
|
|
||||||
|
|
||||||
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
|
|
||||||
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
|
|
||||||
// abandon controls, and flying planes show their current leg.
|
|
||||||
const clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
|
|
||||||
// reason it's here even when grounded with nothing on board.
|
|
||||||
const onboard = state
|
|
||||||
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
|
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
|
||||||
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
|
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||||
// 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.
|
||||||
const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
|
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||||||
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
|
// queued instead of started immediately (see relocate-go below).
|
||||||
|
const relocatable = state ? state.planes : [];
|
||||||
|
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||||
const relocateDest =
|
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; reuses `grounded` above, and with state null falls back to null.
|
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||||
const refuelPlane =
|
|
||||||
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
|
||||||
refuelable[0] ??
|
|
||||||
null;
|
|
||||||
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
|
||||||
|
|
||||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
|
||||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
|
||||||
// user releases the slider (browsers fire a native 'change' on release). The server's
|
|
||||||
// [1,1000] clamp stays as the backstop.
|
|
||||||
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
|
|
||||||
const pendingRef = useRef<number | null>(null);
|
|
||||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const setPending = useCallback((s: number | null) => {
|
|
||||||
pendingRef.current = s;
|
|
||||||
setPendingScaleState(s);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const commitNow = useCallback(() => {
|
|
||||||
if (commitTimerRef.current !== null) {
|
|
||||||
clearTimeout(commitTimerRef.current);
|
|
||||||
commitTimerRef.current = null;
|
|
||||||
}
|
|
||||||
const s = pendingRef.current;
|
|
||||||
if (s === null) return;
|
|
||||||
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
|
|
||||||
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
|
|
||||||
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
|
|
||||||
// call flushes it); only a genuine failure drops it so the display falls back to truth.
|
|
||||||
void applySimControls({ timeScale: s }).then((outcome) => {
|
|
||||||
if (outcome === 'failed') setPending(null);
|
|
||||||
});
|
|
||||||
}, [applySimControls, setPending]);
|
|
||||||
|
|
||||||
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
|
|
||||||
// flight, the optimistic value has been superseded by server truth — clear it (display is
|
|
||||||
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
|
|
||||||
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
|
|
||||||
// quick drag back to the previous scale must not be swallowed before its own commit fires).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
|
|
||||||
}, [state, pendingScale, busy, setPending]);
|
|
||||||
|
|
||||||
const onSliderInput = useCallback(
|
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPending(sliderToScale(Number(e.target.value)));
|
|
||||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
|
||||||
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
|
|
||||||
},
|
|
||||||
[commitNow, setPending],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sliderRef = useRef<HTMLInputElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = sliderRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
|
|
||||||
return () => el.removeEventListener('change', commitNow);
|
|
||||||
}, [commitNow]);
|
|
||||||
|
|
||||||
// Drop any pending debounce timer on unmount.
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
||||||
// in an effect after hydration, so server and client render identically) and follows the
|
// in an effect after hydration, so server and client render identically) and follows the
|
||||||
@@ -309,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
? 'Notifications unavailable'
|
? 'Notifications unavailable'
|
||||||
: 'Enable notifications';
|
: 'Enable notifications';
|
||||||
|
|
||||||
const displayedScale = pendingScale ?? timeScale;
|
// ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
|
||||||
|
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
|
||||||
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
|
// paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
|
||||||
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
|
// the server clock).
|
||||||
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
|
const simClockMs = useSimClock(state);
|
||||||
// penalty previews stay snapshot-based (deterministic against the server clock).
|
|
||||||
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside data-testid="left-pane" className="pane left-pane">
|
<aside data-testid="left-pane" className="pane left-pane">
|
||||||
@@ -341,185 +137,186 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<p className="pane-muted">No planes.</p>
|
<p className="pane-muted">No planes.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="fleet-list" className="fleet-list">
|
<ul data-testid="fleet-list" className="fleet-list">
|
||||||
{state.planes.map((p) => (
|
{state.planes.map((p) => {
|
||||||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
|
||||||
<li
|
// On board section's cargo rows and queue legs, so all plane info lives in one place.
|
||||||
key={p.id}
|
const cargo = state.packages.filter(
|
||||||
data-testid={`plane-${p.id}`}
|
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||||
className="plane-row"
|
);
|
||||||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
return (
|
||||||
onMouseLeave={() => onHover(null)}
|
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
|
||||||
>
|
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
|
||||||
<div className="plane-main">
|
// the whole block without flicker between sub-elements; cargo rows override with their
|
||||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
// own package hover (below).
|
||||||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
<li
|
||||||
</div>
|
key={p.id}
|
||||||
<div className="fuel-bar" aria-hidden="true">
|
data-testid={`plane-${p.id}`}
|
||||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
className="plane-row"
|
||||||
</div>
|
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||||||
<div className="plane-sub">
|
onMouseLeave={() => onHover(null)}
|
||||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
>
|
||||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
<div className="plane-main">
|
||||||
<span className="plane-loc">{planeLocation(p)}</span>
|
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||||
</div>
|
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
|
||||||
</li>
|
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
|
||||||
))}
|
{p.ownerName}
|
||||||
|
</span>
|
||||||
|
{p.status === 'flying' ? (
|
||||||
|
<>
|
||||||
|
<span className="plane-status status-flying">in flight</span>
|
||||||
|
{p.destName && (
|
||||||
|
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
|
||||||
|
{p.locationName} → {p.destName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
|
||||||
|
{p.status === 'repairing' && (
|
||||||
|
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
|
||||||
|
)}
|
||||||
|
{p.health <= 0 && (
|
||||||
|
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="fuel-bar" aria-hidden="true">
|
||||||
|
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
|
||||||
|
<div className="health-bar" aria-hidden="true">
|
||||||
|
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="plane-sub">
|
||||||
|
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
|
||||||
|
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
|
||||||
|
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
|
||||||
|
{p.status === 'available' && p.health < 100 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`repair-${p.id}`}
|
||||||
|
className="pane-btn repair-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
|
||||||
|
>
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{cargo.map((pkg) => {
|
||||||
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
|
// ISS-077 (v0.41.0): while flying the click would POST /queue — disable it when the
|
||||||
|
// destination is already active or queued (the server rejects that with 409). Grounded
|
||||||
|
// clicks go to /fly, which has no such conflict.
|
||||||
|
const alreadyQueued = p.status === 'flying' && (p.destIcao === pkg.destIcao || p.queue.includes(pkg.destIcao));
|
||||||
|
return (
|
||||||
|
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||||
|
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||||
|
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
||||||
|
// mouseleave fires before the parent's).
|
||||||
|
<div
|
||||||
|
key={pkg.id}
|
||||||
|
data-testid={`onboard-package-${pkg.id}`}
|
||||||
|
className="contract-row"
|
||||||
|
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
|
||||||
|
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
|
className="pane-btn onboard-fly-btn"
|
||||||
|
disabled={actionsBusy || alreadyQueued}
|
||||||
|
title={alreadyQueued ? 'Already in flight queue' : undefined}
|
||||||
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||||||
|
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
|
>
|
||||||
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
|
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||||||
|
Fly to {pkg.destIcao}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* ISS-082 (v0.42.0): the per-leg Refuel button is gone — dispatch auto-refuels. */}
|
||||||
|
<div className="onboard-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`abandon-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy || p.status !== 'available'}
|
||||||
|
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||||
|
>
|
||||||
|
Abandon{' '}
|
||||||
|
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="contract-sub">
|
||||||
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
|
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||||
|
{pkg.originIcao} → {pkg.destIcao}
|
||||||
|
</span>
|
||||||
|
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
|
||||||
|
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||||
|
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||||
|
{pkg.special && (
|
||||||
|
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||||
|
{formatSpecialLabel(pkg.special)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{formatCash(pkg.reward)}</span>
|
||||||
|
{pkg.status === 'expired' ? (
|
||||||
|
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||||
|
) : (
|
||||||
|
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
|
||||||
|
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
||||||
|
/queue while a plane is flying, so no separate entry point is needed.
|
||||||
|
ISS-054 (v0.33.0): rendered only when non-empty. */}
|
||||||
|
{p.queue.length > 0 && (
|
||||||
|
<div className="queue-subsection">
|
||||||
|
<span className="queue-heading">Queue</span>
|
||||||
|
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
|
||||||
|
duplicated in the DOM and broke Playwright strict mode. */}
|
||||||
|
{p.queue.map((icao, i) => (
|
||||||
|
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
|
||||||
|
<span className="contract-route">
|
||||||
|
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`queue-cancel-${p.id}-${i}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
|
|
||||||
<section className="pane-section">
|
|
||||||
<h3 className="pane-heading">On board</h3>
|
|
||||||
{onboard.length === 0 ? (
|
|
||||||
<p className="pane-muted">Nothing on board.</p>
|
|
||||||
) : (
|
|
||||||
onboard.map((plane) => {
|
|
||||||
const cargo = state.packages.filter(
|
|
||||||
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
|
|
||||||
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
|
|
||||||
// child, so this covers the whole block without flicker between sub-elements.
|
|
||||||
<div
|
|
||||||
key={plane.id}
|
|
||||||
data-testid={`onboard-${plane.id}`}
|
|
||||||
className="onboard-block"
|
|
||||||
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
|
|
||||||
onMouseLeave={() => onHover(null)}
|
|
||||||
>
|
|
||||||
<div className="contract-main">
|
|
||||||
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
|
|
||||||
{plane.status === 'flying' ? (
|
|
||||||
<>
|
|
||||||
<span className="plane-status status-flying">in flight</span>
|
|
||||||
{plane.destName && (
|
|
||||||
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
|
|
||||||
{plane.locationName} → {plane.destName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="plane-status status-available">{plane.locationIcao}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cargo.map((pkg) => {
|
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
|
||||||
return (
|
|
||||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
|
||||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
|
||||||
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
|
||||||
// mouseleave fires before the parent's).
|
|
||||||
<div
|
|
||||||
key={pkg.id}
|
|
||||||
data-testid={`onboard-package-${pkg.id}`}
|
|
||||||
className="contract-row"
|
|
||||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
|
|
||||||
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`fly-to-${pkg.id}`}
|
|
||||||
className="pane-btn onboard-fly-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
|
|
||||||
>
|
|
||||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
|
||||||
Fly to {pkg.destName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div className="onboard-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`refuel-for-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
|
||||||
>
|
|
||||||
Refuel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`abandon-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
|
||||||
>
|
|
||||||
Abandon{' '}
|
|
||||||
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="contract-sub">
|
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
|
||||||
<span>{formatCash(pkg.reward)}</span>
|
|
||||||
{pkg.status === 'expired' ? (
|
|
||||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
|
||||||
) : (
|
|
||||||
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
|
|
||||||
add control reusing this pane's already-fetched airport options (no second fetch). */}
|
|
||||||
<div className="queue-subsection">
|
|
||||||
<span className="queue-heading">Queue</span>
|
|
||||||
{plane.queue.map((icao, i) => (
|
|
||||||
<div key={`${icao}-${i}`} data-testid={`queue-leg-${i}`} className="queue-row">
|
|
||||||
<span className="contract-route">
|
|
||||||
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`queue-cancel-${i}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy}
|
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="market-row">
|
|
||||||
<select
|
|
||||||
data-testid="queue-add-dest"
|
|
||||||
aria-label="Airport to add to the flight queue"
|
|
||||||
value={queueAddDestIcao ?? ''}
|
|
||||||
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
|
|
||||||
>
|
|
||||||
<option value="">Select an airport…</option>
|
|
||||||
{airportOptions?.map((a) => (
|
|
||||||
<option key={a.icao} value={a.icao}>
|
|
||||||
{a.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="queue-add"
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || queueAddDestIcao === null}
|
|
||||||
onClick={() => {
|
|
||||||
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add to queue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
||||||
everything else about our planes, between On board and Time. */}
|
everything else about our planes, between Fleet and Time. */}
|
||||||
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
||||||
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
||||||
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
|
With no planes or no loaded airport options there is nothing to preview → null. */}
|
||||||
<section
|
<section
|
||||||
className="pane-section"
|
className="pane-section"
|
||||||
data-testid="relocate-section"
|
data-testid="relocate-section"
|
||||||
@@ -533,8 +330,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
onMouseLeave={() => onHover(null)}
|
onMouseLeave={() => onHover(null)}
|
||||||
>
|
>
|
||||||
<h3 className="pane-heading">Relocate</h3>
|
<h3 className="pane-heading">Relocate</h3>
|
||||||
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
|
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
|
||||||
repositioning, this row just exposes it for planes with nothing on board. */}
|
repositioning, this row just exposes it for planes with nothing on board.
|
||||||
|
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
|
||||||
<div className="market-row market-row-relocate">
|
<div className="market-row market-row-relocate">
|
||||||
<select
|
<select
|
||||||
data-testid="relocate-plane"
|
data-testid="relocate-plane"
|
||||||
@@ -542,7 +340,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
value={relocatePlane?.id ?? ''}
|
value={relocatePlane?.id ?? ''}
|
||||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||||
>
|
>
|
||||||
{grounded.map((p) => (
|
{relocatable.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
||||||
</option>
|
</option>
|
||||||
@@ -555,7 +353,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>
|
||||||
))}
|
))}
|
||||||
@@ -564,10 +364,12 @@ 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
|
||||||
|
// relocation takes off when the current one lands.
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (relocatePlane && relocateDest) {
|
if (relocatePlane && relocateDest) {
|
||||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -576,78 +378,13 @@ 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 On board (mode 'forFlight') is a different feature and stays there. */}
|
|
||||||
<div className="market-row">
|
|
||||||
{refuelable.length === 0 ? (
|
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
|
||||||
All tanks full.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
data-testid="market-refuel-plane"
|
|
||||||
aria-label="Plane to refuel"
|
|
||||||
value={refuelPlane?.id ?? ''}
|
|
||||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
{refuelable.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span data-testid="market-refuel-cost" className="market-preview">
|
|
||||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="market-refuel"
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || refuelPlane === null}
|
|
||||||
onClick={() => {
|
|
||||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refuel to full
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Time</h3>
|
<h3 className="pane-heading">Time</h3>
|
||||||
<p data-testid="sim-clock" className="sim-clock">
|
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
|
||||||
{simClockMs !== null && formatSimClock(simClockMs)}
|
only the clock display remains here. */}
|
||||||
{paused ? ' (paused)' : ''}
|
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
|
||||||
</p>
|
|
||||||
<div className="time-controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="pause-toggle"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void applySimControls({ paused: !paused })}
|
|
||||||
>
|
|
||||||
{paused ? 'Resume' : 'Pause'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={sliderRef}
|
|
||||||
type="range"
|
|
||||||
data-testid="time-scale-slider"
|
|
||||||
aria-label="Time scale"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={SCALE_SLIDER_STEP}
|
|
||||||
value={scaleToSlider(displayedScale)}
|
|
||||||
onChange={onSliderInput}
|
|
||||||
/>
|
|
||||||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
|
||||||
</div>
|
|
||||||
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+95
-25
@@ -11,12 +11,19 @@ import type { HoverTarget } from './hoverTypes';
|
|||||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import AdminPanel from './AdminPanel';
|
||||||
|
import Leaderboard from './Leaderboard';
|
||||||
|
import { clearStoredSession, sessionHeaders } from '../lib/authClient';
|
||||||
|
|
||||||
const EUROPE_CENTER: [number, number] = [10, 52];
|
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
||||||
const INITIAL_ZOOM = 4;
|
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
||||||
|
// so both continents are fully visible on a typical desktop map pane.
|
||||||
|
const WORLD_CENTER: [number, number] = [0, 0];
|
||||||
|
const INITIAL_ZOOM = 2;
|
||||||
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
|
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
|
||||||
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
|
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
|
||||||
export const MIN_ZOOM = 3;
|
export const MIN_ZOOM = 0; // v0.70.0: fully zoomed out the whole earth (512 px world) fits any viewport
|
||||||
export const MAX_ZOOM = 11;
|
export const MAX_ZOOM = 11;
|
||||||
|
|
||||||
const AIRPORTS_SOURCE_ID = 'airports';
|
const AIRPORTS_SOURCE_ID = 'airports';
|
||||||
@@ -32,6 +39,8 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
|||||||
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
||||||
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
||||||
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
||||||
|
// ISS-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
|
||||||
|
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
|
||||||
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
||||||
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
||||||
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
||||||
@@ -62,11 +71,16 @@ interface MapProps {
|
|||||||
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
|
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
|
||||||
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
|
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
|
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
|
||||||
|
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
||||||
|
onLogout?: () => void;
|
||||||
|
// v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button.
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
||||||
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||||
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
// /api/airports is fetched at most once per page life: every styledata event
|
// /api/airports is fetched at most once per page life: every styledata event
|
||||||
@@ -84,6 +98,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
||||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||||
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||||
|
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
||||||
|
// stored session on every open.
|
||||||
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
|
||||||
|
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||||
|
|
||||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||||
@@ -94,7 +113,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const st = stateRef.current;
|
const st = stateRef.current;
|
||||||
if (!map || !st) return;
|
if (!map || !st) return;
|
||||||
const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
if (planesSource) void planesSource.setData(planesGeoJson(st)); // v6 API (setGeoData was removed)
|
// ISS-124 (v0.58.0): the airport index unwraps crossing markers — empty until it loads, so
|
||||||
|
// planes draw at their wrapped position in the meantime (same as pre-v0.58.0).
|
||||||
|
if (planesSource) void planesSource.setData(planesGeoJson(st, airportsIndexRef.current ?? new Map())); // v6 API (setGeoData was removed)
|
||||||
const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
if (routesSource && airportsIndexRef.current) {
|
if (routesSource && airportsIndexRef.current) {
|
||||||
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
||||||
@@ -153,10 +174,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 —
|
||||||
@@ -204,7 +235,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
|
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
|
||||||
center: EUROPE_CENTER,
|
center: WORLD_CENTER,
|
||||||
zoom: INITIAL_ZOOM,
|
zoom: INITIAL_ZOOM,
|
||||||
minZoom: MIN_ZOOM,
|
minZoom: MIN_ZOOM,
|
||||||
maxZoom: MAX_ZOOM,
|
maxZoom: MAX_ZOOM,
|
||||||
@@ -221,21 +252,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||||
const w = weatherFor(p.icao);
|
const w = weatherFor(p.icao);
|
||||||
const localTime = approxLocalTime(p.lon, Date.now());
|
const localTime = approxLocalTime(p.lon, Date.now());
|
||||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
|
||||||
|
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
|
||||||
const st = stateRef.current;
|
const st = stateRef.current;
|
||||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
const planes = st ? st.planes : [];
|
||||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||||
const relocateHtml =
|
const relocateHtml =
|
||||||
groundedPlanes.length > 0
|
planes.length > 0
|
||||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||||
`<option value="">Select a plane\u2026</option>` +
|
`<option value="">Select a plane\u2026</option>` +
|
||||||
groundedPlanes.map(
|
planes.map(
|
||||||
(pl) =>
|
(pl) =>
|
||||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||||
).join('') +
|
).join('') +
|
||||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||||
const popup = new maplibregl.Popup({ offset: 10 })
|
const popup = new maplibregl.Popup({ offset: 10 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
@@ -253,7 +285,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const el = popup.getElement();
|
const el = popup.getElement();
|
||||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
// The status span is always in the popup HTML (even with no planes), so it
|
||||||
// exists whenever the select/button do.
|
// exists whenever the select/button do.
|
||||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||||
if (select && button && statusEl) {
|
if (select && button && statusEl) {
|
||||||
@@ -266,16 +298,21 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
statusEl.textContent = 'Relocating\u2026';
|
statusEl.textContent = 'Relocating\u2026';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||||
|
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||||
|
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||||
|
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||||
|
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
|
||||||
|
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
|
||||||
body: JSON.stringify({ destIcao: p.icao }),
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||||
statusEl.textContent = 'Relocation started.';
|
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||||
button.disabled = false; // allow retry / picking another plane
|
button.disabled = false; // allow retry / picking another plane
|
||||||
@@ -341,7 +378,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
source: AIRPORTS_SOURCE_ID,
|
source: AIRPORTS_SOURCE_ID,
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': 5, // ISS-010 (v0.18.0): bigger — the r=3 dots were hard to click
|
'circle-radius': 5, // ISS-010 (v0.18.0): bigger — the r=3 dots were hard to click
|
||||||
'circle-color': '#e11d48',
|
'circle-color': '#f43f5e', // ISS-118 (v0.60.0): rose-500 — old #e11d48 (rose-600) was tuned for light tiles; 1.7:1 on Fiord's dark slate land
|
||||||
'circle-stroke-width': 1.5,
|
'circle-stroke-width': 1.5,
|
||||||
'circle-stroke-color': '#ffffff',
|
'circle-stroke-color': '#ffffff',
|
||||||
},
|
},
|
||||||
@@ -367,8 +404,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
||||||
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
|
// layers so a hovered trace still draws on top of it. Amber/gold + fine dots reads as
|
||||||
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
|
// "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
|
||||||
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
||||||
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
@@ -381,14 +418,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: QUEUE_ROUTES_SOURCE_ID,
|
source: QUEUE_ROUTES_SOURCE_ID,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#64748b', // muted slate — planned legs, not actively flying
|
'line-color': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
|
||||||
'line-width': 3,
|
'line-width': 3,
|
||||||
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!map.getSource(PLANES_SOURCE_ID)) {
|
if (!map.getSource(PLANES_SOURCE_ID)) {
|
||||||
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
// ISS-124 (v0.58.0): same airport-index unwrap as applyDynamic above.
|
||||||
|
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()) });
|
||||||
}
|
}
|
||||||
if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) {
|
if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -442,7 +480,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
source: ROUTES_SOURCE_ID,
|
source: ROUTES_SOURCE_ID,
|
||||||
filter: ['==', ['get', 'planeId'], ''],
|
filter: ['==', ['get', 'planeId'], ''],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#ef4444', // red — distinct from the amber routes-legs line
|
'line-color': '#f87171', // ISS-118 (v0.60.0): red-400 — old #ef4444 sat at 2.1:1 on Fiord's dark slate; brighter so the hover emphasis reads, still distinct from the amber routes-legs line
|
||||||
'line-width': 6,
|
'line-width': 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -533,11 +571,21 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort
|
||||||
|
// server-side session kill, drop the stored id, tell the shell to show sign-in again.
|
||||||
|
const onTopBarLogout = async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined);
|
||||||
|
clearStoredSession();
|
||||||
|
onLogout?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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,10 +600,32 @@ 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>
|
||||||
|
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
|
||||||
|
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||||
|
Leaderboard
|
||||||
|
</button>
|
||||||
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
|
||||||
|
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
|
Admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
|
||||||
|
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||||
|
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||||
|
<AdminPanel onLogout={onLogout} />
|
||||||
|
</Modal>
|
||||||
|
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
|
||||||
|
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
|
||||||
|
<Leaderboard players={state?.players ?? []} />
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack),
|
||||||
|
// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it).
|
||||||
|
// Foundation for future modal-based features; keep this generic.
|
||||||
|
export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+39
-12
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { maxRangeKm } from '@/lib/sim/movement';
|
import { guaranteedRangeKm } from '@/lib/sim/movement';
|
||||||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -49,8 +49,9 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
|
|||||||
// Load controls for one open package: a single Load button when exactly one plane
|
// Load controls for one open package: a single Load button when exactly one plane
|
||||||
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||||
// several are, and a disabled button with the reason otherwise. Each row owns its
|
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||||
// selection in local state and defaults to the "Select a plane…" placeholder —
|
// selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
|
||||||
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
|
// first eligible plane so Load is one-click out of the box (matching the single-plane
|
||||||
|
// UX) while the picker still allows choosing a different plane.
|
||||||
function LoadControls({
|
function LoadControls({
|
||||||
pkg,
|
pkg,
|
||||||
eligible,
|
eligible,
|
||||||
@@ -63,8 +64,9 @@ function LoadControls({
|
|||||||
onLoad: (planeId: string) => void;
|
onLoad: (planeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
// The user's pick while it stays eligible, else nothing (placeholder).
|
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
|
||||||
const effective = eligible.find((p) => p.id === selectedId) ?? null;
|
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
|
||||||
|
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
|
||||||
|
|
||||||
if (eligible.length === 1) {
|
if (eligible.length === 1) {
|
||||||
return (
|
return (
|
||||||
@@ -149,6 +151,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
||||||
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
||||||
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
|
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
|
||||||
|
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
|
||||||
|
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside data-testid="right-pane" className="pane right-pane">
|
<aside data-testid="right-pane" className="pane right-pane">
|
||||||
@@ -198,6 +203,18 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
>
|
>
|
||||||
<div className="contract-main">
|
<div className="contract-main">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||||
|
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||||
|
{pkg.special && (
|
||||||
|
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||||
|
{formatSpecialLabel(pkg.special)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
|
||||||
|
{pkg.carrierName && (
|
||||||
|
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
|
||||||
|
{pkg.carrierName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{eligible.length > 0 ? (
|
{eligible.length > 0 ? (
|
||||||
<LoadControls
|
<LoadControls
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
@@ -213,9 +230,14 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
|
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
|
||||||
|
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||||
|
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||||
|
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||||
|
{/* v0.65.0: open jobs expire on the WALL clock — live countdown from expiresRealMs;
|
||||||
|
re-renders every poll tick (~1 s) keep it current without a timer. */}
|
||||||
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||||
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
{formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -246,8 +268,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span data-testid="market-buy-price" className="market-preview">
|
<span data-testid="market-buy-price" className="market-preview">
|
||||||
{buyEntry ? formatCash(buyEntry.price) : ''}
|
{buyPrice !== null ? formatCash(buyPrice) : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
|
||||||
|
{planeDiscountRate > 0 && (
|
||||||
|
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="market-buy"
|
data-testid="market-buy"
|
||||||
@@ -294,7 +320,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||||||
|
|
||||||
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
|
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
|
||||||
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
|
All values come from the snapshot's catalog; max range is the GUARANTEED full-tank range —
|
||||||
|
worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */}
|
||||||
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
||||||
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
||||||
<h4 className="plane-type-name">{e.label}</h4>
|
<h4 className="plane-type-name">{e.label}</h4>
|
||||||
@@ -325,7 +352,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span>Max range</span>
|
<span>Max range</span>
|
||||||
<strong data-testid={`plane-type-range-${e.model}`}>
|
<strong data-testid={`plane-type-range-${e.model}`}>
|
||||||
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
{Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { storeSession } from '@/lib/authClient';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is
|
||||||
|
// stored. Both forms POST the same shape ({username, password}); login mints a session for an
|
||||||
|
// existing account, register creates one first and then mints it. On success the sessionId goes
|
||||||
|
// to localStorage (via storeSession) and onAuthed() flips the shell back in.
|
||||||
|
|
||||||
|
interface SignInScreenProps {
|
||||||
|
onAuthed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignInScreen({ onAuthed }: SignInScreenProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent<HTMLFormElement>, path: '/api/login' | '/api/register') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: String(form.get('username') ?? ''),
|
||||||
|
password: String(form.get('password') ?? ''),
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { sessionId?: string; error?: string };
|
||||||
|
if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
storeSession(data.sessionId);
|
||||||
|
onAuthed();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sign-in">
|
||||||
|
<h1 data-testid="app-title">Flight Ops</h1>
|
||||||
|
{error !== null && (
|
||||||
|
<p data-testid="auth-error" role="alert" className="sign-in-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
|
||||||
|
<label htmlFor="sign-in-username">Username</label>
|
||||||
|
<input id="sign-in-username" data-testid="sign-in-username" name="username" autoComplete="username" required />
|
||||||
|
<label htmlFor="sign-in-password">Password</label>
|
||||||
|
<input id="sign-in-password" data-testid="sign-in-password" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="sign-in-divider">or</p>
|
||||||
|
|
||||||
|
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
|
||||||
|
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
|
||||||
|
<input id="register-username" name="username" autoComplete="off" required />
|
||||||
|
<label htmlFor="register-password">Password (at least 8 characters)</label>
|
||||||
|
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
|
||||||
|
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane';
|
|
||||||
|
|
||||||
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
|
|
||||||
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
|
|
||||||
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
|
||||||
|
|
||||||
describe('pendingScaleConfirmed', () => {
|
|
||||||
it('is false while there is no pending value', () => {
|
|
||||||
expect(pendingScaleConfirmed(null, 1)).toBe(false);
|
|
||||||
expect(pendingScaleConfirmed(null, 64)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
|
|
||||||
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
|
|
||||||
expect(pendingScaleConfirmed(64, 1)).toBe(false);
|
|
||||||
expect(pendingScaleConfirmed(32, 8)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('becomes true exactly when a snapshot carries the pending scale', () => {
|
|
||||||
expect(pendingScaleConfirmed(64, 64)).toBe(true);
|
|
||||||
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the
|
|
||||||
// error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer
|
|
||||||
// intent supersedes the in-flight body instead of waiting out its retries.
|
|
||||||
|
|
||||||
describe('retrySimPost', () => {
|
|
||||||
it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => {
|
|
||||||
expect(SIM_POST_MAX_ATTEMPTS).toBe(3);
|
|
||||||
expect(SIM_POST_RETRY_DELAY_MS).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('succeeds on the first attempt without sleeping or retrying', async () => {
|
|
||||||
const post = vi.fn(async () => {});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
|
|
||||||
expect(post).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sleep).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retries with the backoff delay and succeeds on a later attempt', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
if (++calls < 3) throw new Error('dropped');
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
|
|
||||||
expect(post).toHaveBeenCalledTimes(3);
|
|
||||||
expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts
|
|
||||||
expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives up after the final attempt with the last error (no trailing sleep)', async () => {
|
|
||||||
const err = new Error('down');
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
const result = await retrySimPost(post, () => false, sleep);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.reason).toBe('exhausted');
|
|
||||||
if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error
|
|
||||||
}
|
|
||||||
expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS);
|
|
||||||
expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
calls++; // flip "superseded" right after the first failure, like a queued commitNow would
|
|
||||||
throw new Error('dropped');
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
|
|
||||||
expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead
|
|
||||||
expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never even sends a body that is already superseded', async () => {
|
|
||||||
const post = vi.fn(async () => {});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
|
|
||||||
expect(post).not.toHaveBeenCalled();
|
|
||||||
expect(sleep).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
|
||||||
|
|
||||||
|
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
|
||||||
|
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
|
||||||
|
|
||||||
|
describe('queued route line color', () => {
|
||||||
|
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
|
||||||
|
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,11 @@ 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: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -39,10 +43,14 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
weightKg: 1_000,
|
weightKg: 1_000,
|
||||||
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
|
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||||
deadlineSimTime: 1_000_000,
|
deadlineSimTime: 1_000_000,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -50,8 +58,9 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
||||||
return {
|
return {
|
||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0 },
|
||||||
planes,
|
planes,
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
|
||||||
packages,
|
packages,
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
|
|||||||
@@ -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
@@ -14,6 +14,11 @@ services:
|
|||||||
image: flight-ops-ci
|
image: flight-ops-ci
|
||||||
environment:
|
environment:
|
||||||
FLIGHT_TEST_HOOKS: "1"
|
FLIGHT_TEST_HOOKS: "1"
|
||||||
|
# v0.65.0: shorten the pool refill cooldowns to seconds so e2e can watch taken slots come back;
|
||||||
|
# the job LIFETIME stays at its 1 h default on purpose — state.spec.ts restarts the container
|
||||||
|
# mid-suite and must not race a whole board expiring (the expire-package hook ages jobs instead).
|
||||||
|
FLIGHT_JOB_REFILL_MS: "5000"
|
||||||
|
FLIGHT_JOB_SMALL_REFILL_MS: "2000"
|
||||||
volumes:
|
volumes:
|
||||||
# Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public
|
# Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public
|
||||||
# before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's
|
# before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's
|
||||||
|
|||||||
+5
-6
@@ -6,13 +6,12 @@ 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:
|
# No environment on purpose (#135, v0.62.0): FLIGHT_TEST_HOOKS must NOT be set in production —
|
||||||
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
# it enables the /api/test/* hooks AND makes tickOnce honor legacy stored timeScale values
|
||||||
# /api/test/expire-package so the suites can force known routes/expiries;
|
# (pre-v0.52.0 saves hold 1000). The e2e suite runs only in CI against docker-compose.ci.yml,
|
||||||
# 404 without this var.
|
# which sets the var itself.
|
||||||
FLIGHT_TEST_HOOKS: "1"
|
|
||||||
networks:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+117
-83
@@ -1,5 +1,15 @@
|
|||||||
import type { Page, Request } from '@playwright/test';
|
import type { Page, Request } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// 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
|
||||||
@@ -9,14 +19,13 @@ import { expect, test } from '@playwright/test';
|
|||||||
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
||||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model a full tank covers the
|
|
||||||
// round trip, but the return leaves too little for another outbound
|
|
||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
|
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
||||||
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
||||||
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
|
|
||||||
const REP_BASE = 1; // lib/sim/engine.ts
|
const REP_BASE = 1; // lib/sim/engine.ts
|
||||||
const REP_PER_KM = 0.0005;
|
const REP_PER_KM = 0.0005;
|
||||||
|
|
||||||
@@ -37,7 +46,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||||
packages: Array<{
|
packages: Array<{
|
||||||
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
||||||
@@ -47,9 +56,14 @@ interface StateBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.ok()).toBeTruthy();
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
||||||
@@ -67,20 +81,47 @@ async function createPackage(
|
|||||||
return (await res.json()) as StateBody['packages'][number];
|
return (await res.json()) as StateBody['packages'][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
expect(res.ok()).toBeTruthy();
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
|
||||||
// resume it or the clock stays frozen and the next wait hangs.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is why the expiry
|
||||||
|
// test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health
|
||||||
|
// and no gate afterwards.
|
||||||
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, 'plane-starter', { health: 50 });
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
||||||
@@ -100,10 +141,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
|
// Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
|
||||||
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
@@ -111,7 +152,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×).
|
||||||
@@ -128,8 +173,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
@@ -144,12 +188,16 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own
|
||||||
|
// dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so
|
||||||
|
// exact-cash assertions never see that cost.
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights + refuel
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights
|
||||||
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
@@ -181,8 +229,12 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
// package shows as loaded on the starter in the on-board section.
|
// package shows as loaded on the starter in the on-board section.
|
||||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||||
|
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||||
|
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||||
|
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
|
||||||
|
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||||
|
|
||||||
// FLY via the UI: click the destination name button in the on-board list.
|
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
await expect(flyBtn).toBeEnabled();
|
await expect(flyBtn).toBeEnabled();
|
||||||
await flyBtn.click();
|
await flyBtn.click();
|
||||||
@@ -219,24 +271,25 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
||||||
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
||||||
|
|
||||||
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
|
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
|
||||||
|
// wind-adjusted (v0.36.0) by departure weather vs route heading.
|
||||||
const entry = before.catalog.find((e) => e.model === 'atr72')!;
|
const entry = before.catalog.find((e) => e.model === 'atr72')!;
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
expect(landed.fuel).toBeCloseTo(
|
expect(landed.fuel).toBeCloseTo(
|
||||||
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
|
fuelAfter(
|
||||||
|
before.planes.find((p) => p.id === planeId)!.fuel,
|
||||||
|
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||||||
|
entry.emptyWeightKg,
|
||||||
|
entry.maxFuel,
|
||||||
|
exactKm,
|
||||||
|
),
|
||||||
6,
|
6,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
|
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
||||||
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
|
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
||||||
expect(refuelRes.ok()).toBeTruthy();
|
|
||||||
s = await getState(page);
|
|
||||||
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
|
|
||||||
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
|
|
||||||
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
|
|
||||||
|
|
||||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
// Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
|
||||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(homeRes.ok()).toBeTruthy();
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
@@ -282,9 +335,8 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||||
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
|
test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
const before = await getState(page);
|
|
||||||
|
|
||||||
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||||
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
||||||
@@ -298,12 +350,23 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||||
await setScale(page, SCALE);
|
// window so scale 10_000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||||
let s = await getState(page);
|
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||||
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
await activateWorld(page);
|
||||||
s = await getState(page);
|
try {
|
||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||||
|
await setScale(page, FAST);
|
||||||
|
let s = await getState(page);
|
||||||
|
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||||
|
s = await getState(page);
|
||||||
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
||||||
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
|
||||||
|
let s = before;
|
||||||
|
|
||||||
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
||||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
||||||
@@ -325,17 +388,16 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
// Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
// Restore: top up + fly home at FAST (pure arrival wait).
|
||||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
await setFuelFull(page, 'plane-starter');
|
||||||
await page.request.post('/api/planes/plane-starter/refuel');
|
await setScale(page, FAST);
|
||||||
await setScale(page, SCALE);
|
|
||||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(homeRes.ok()).toBeTruthy();
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // the FAR round trip alone is ~30 s real time at 1000×
|
test.setTimeout(300_000); // self-heal + one short hop each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Load a package whose origin the plane is not at → 409.
|
// Load a package whose origin the plane is not at → 409.
|
||||||
@@ -366,44 +428,16 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
|||||||
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
expect(res.status()).toBe(409);
|
expect(res.status()).toBe(409);
|
||||||
|
|
||||||
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
|
// Fly home from the hop so the fuel section starts at the hub, at SCALE.
|
||||||
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
||||||
|
|
||||||
// Insufficient fuel (v0.16.0 weighted burn): a full tank covers the FAR round trip —
|
// ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
|
||||||
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
|
// auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
|
||||||
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
|
// insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
|
||||||
const s0 = await getState(page);
|
// app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
|
|
||||||
if (starter.fuel < starter.maxFuel) {
|
|
||||||
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long legs
|
|
||||||
}
|
|
||||||
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
|
|
||||||
expect(farRes.ok()).toBeTruthy();
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
|
||||||
|
|
||||||
const sFar = await getState(page);
|
|
||||||
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(atFar.locationIcao).toBe(FAR);
|
|
||||||
// The return leg fits: the weighted burn over it is less than what's left in the tank.
|
|
||||||
// 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).
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// v0.71.0 — Admin panel e2e: top-bar button → modal; wrong credentials show an error; a session
|
||||||
|
// minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates WITHOUT the real
|
||||||
|
// admin password ever appearing in test code; then the generic DB grid is exercised end-to-end:
|
||||||
|
// table list → edit + save a cell (verified by an independent re-fetch) → add a row → delete it →
|
||||||
|
// logout. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated
|
||||||
|
// visitors see IT instead of the shell, and logout drops back to it.
|
||||||
|
|
||||||
|
const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||||
|
|
||||||
|
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
|
||||||
|
const res = await page.request.post('/api/test/admin-session');
|
||||||
|
expect(res.status(), 'admin-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||||
|
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('admin panel: sign-in gate, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107).
|
||||||
|
const signIn = page.getByTestId('sign-in-form');
|
||||||
|
await expect(signIn).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('app-header')).toHaveCount(0);
|
||||||
|
|
||||||
|
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
|
||||||
|
await page.getByTestId('sign-in-username').fill('kamilkrzywda');
|
||||||
|
await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose');
|
||||||
|
await page.getByTestId('sign-in-submit').click();
|
||||||
|
await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// --- Authenticate via the test hook + localStorage, then reload into the shell.
|
||||||
|
const sessionId = await mintAdminSession(page);
|
||||||
|
// Both values must be passed as arguments — the function runs in the browser, where Node-side
|
||||||
|
// variables are not in scope.
|
||||||
|
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
|
||||||
|
// The independent re-fetch below goes through page.request → needs the header at context level.
|
||||||
|
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
|
||||||
|
await page.reload();
|
||||||
|
const adminBtn = page.getByTestId('admin-btn');
|
||||||
|
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// --- Open the modal: stored session is valid → straight to the table list.
|
||||||
|
await adminBtn.click();
|
||||||
|
|
||||||
|
// --- Authenticated admin → table list including packages and users.
|
||||||
|
const tables = page.getByTestId('admin-tables');
|
||||||
|
await expect(tables).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('admin-table-packages')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('admin-table-users')).toBeVisible();
|
||||||
|
|
||||||
|
// --- Open packages, edit one cell, Save; verify via an INDEPENDENT re-fetch of the same route.
|
||||||
|
await page.getByTestId('admin-table-packages').click();
|
||||||
|
const grid = page.getByTestId('admin-rows');
|
||||||
|
await expect(grid).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const rowidCell = page.getByTestId('admin-cell-0-__rowid');
|
||||||
|
const originalRowid = Number(await rowidCell.inputValue());
|
||||||
|
expect(Number.isInteger(originalRowid)).toBeTruthy();
|
||||||
|
|
||||||
|
const rewardCell = page.getByTestId('admin-cell-0-reward');
|
||||||
|
await rewardCell.fill('987654');
|
||||||
|
await page.getByTestId('admin-save-0').click();
|
||||||
|
|
||||||
|
// The grid re-fetches after save — the edited cell shows the new value…
|
||||||
|
await expect(rewardCell).toHaveValue('987654', { timeout: 15_000 });
|
||||||
|
// …and an independent request (not the grid's) confirms it persisted server-side.
|
||||||
|
const check = await page.request.get('/api/admin/tables/packages/rows?limit=500', {
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
});
|
||||||
|
expect(check.status()).toBe(200);
|
||||||
|
const fetched = (await check.json()) as { rows: Array<Record<string, unknown>> };
|
||||||
|
const row = fetched.rows.find((r) => Number(r.__rowid) === originalRowid);
|
||||||
|
expect(row, 'edited packages row must still exist').toBeTruthy();
|
||||||
|
expect(Number(row?.reward)).toBe(987654);
|
||||||
|
|
||||||
|
// --- Add a row (users table — fewest required columns), then delete it again.
|
||||||
|
await page.getByTestId('admin-back').click();
|
||||||
|
await expect(tables).toBeVisible();
|
||||||
|
await page.getByTestId('admin-table-users').click();
|
||||||
|
const usersGrid = page.getByTestId('admin-rows');
|
||||||
|
await expect(usersGrid).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const existingRows = await page.getByTestId(/admin-save-\d+/).count();
|
||||||
|
await page.getByTestId('admin-add-row').click();
|
||||||
|
const n = existingRows; // the new empty row is appended last
|
||||||
|
await page.getByTestId(`admin-cell-${n}-id`).fill('user-e2e-admin-test');
|
||||||
|
await page.getByTestId(`admin-cell-${n}-username`).fill('e2e-admin-user');
|
||||||
|
await page.getByTestId(`admin-cell-${n}-password_hash`).fill('aa$bb'); // any string — dev-tool insert, not a real credential
|
||||||
|
await page.getByTestId(`admin-cell-${n}-created_at`).fill(String(Date.now()));
|
||||||
|
await page.getByTestId(`admin-save-${n}`).click();
|
||||||
|
|
||||||
|
// The inserted row is visible after the grid's post-save re-fetch.
|
||||||
|
const bodyRows = usersGrid.locator('tbody tr');
|
||||||
|
const findUserRow = async (): Promise<number> => {
|
||||||
|
const count = await bodyRows.count();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if ((await page.getByTestId(`admin-cell-${i}-username`).inputValue()) === 'e2e-admin-user') return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
await expect.poll(findUserRow, { timeout: 15_000 }).toBeGreaterThanOrEqual(0);
|
||||||
|
const addedIdx = await findUserRow();
|
||||||
|
|
||||||
|
// Delete it again — gone after the post-delete re-fetch.
|
||||||
|
await page.getByTestId(`admin-delete-${addedIdx}`).click();
|
||||||
|
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
|
||||||
|
|
||||||
|
// --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell).
|
||||||
|
await page.getByTestId('admin-logout').click();
|
||||||
|
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(tables).toBeHidden();
|
||||||
|
});
|
||||||
+11
-4
@@ -13,25 +13,32 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
|||||||
expect(body.version).toBe(pkg.version);
|
expect(body.version).toBe(pkg.version);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
|
test('GET /api/openapi serves the spec with all eighteen endpoints and the package.json version', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/openapi');
|
const res = await page.request.get('/api/openapi');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
expect(res.headers()['content-type']).toContain('application/json');
|
expect(res.headers()['content-type']).toContain('application/json');
|
||||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
||||||
expect(spec.info.title).toBe('Flight Ops API');
|
expect(spec.info.title).toBe('Flight Ops API');
|
||||||
expect(spec.info.version).toBe(pkg.version);
|
expect(spec.info.version).toBe(pkg.version);
|
||||||
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
|
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly;
|
||||||
|
// v0.71.0 adds the admin panel (login/logout/me + guarded DB CRUD).
|
||||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||||
|
'/api/admin/tables', // v0.71.0 — admin panel
|
||||||
|
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
||||||
'/api/airports',
|
'/api/airports',
|
||||||
|
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||||
|
'/api/login', // v0.71.0 — admin panel auth
|
||||||
|
'/api/logout', // v0.71.0
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
|
'/api/me', // v0.71.0
|
||||||
'/api/packages/{id}/abandon',
|
'/api/packages/{id}/abandon',
|
||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/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/register', // v1.1.0 (ISS-107) — self-service account creation
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
]);
|
]);
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — session helpers for the e2e suite. Every game route is now session-guarded, so
|
||||||
|
// each spec authenticates in a beforeEach: mint a player session server-side via the test hook
|
||||||
|
// (FLIGHT_TEST_HOOKS=1; no real credentials in test code), then apply it BOTH ways the client uses —
|
||||||
|
// localStorage for the app's own fetches (lib/authClient.ts reads it) and context extraHTTPHeaders
|
||||||
|
// so page.request.* calls in specs carry X-Flight-Session too.
|
||||||
|
|
||||||
|
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||||
|
|
||||||
|
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
|
||||||
|
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
|
||||||
|
// usual local player, exactly as before.
|
||||||
|
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
|
||||||
|
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
|
||||||
|
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||||
|
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySession(page: Page, sessionId: string): Promise<void> {
|
||||||
|
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
|
||||||
|
// Runs before every navigation's scripts — the app reads the session from localStorage on load.
|
||||||
|
await page.addInitScript(
|
||||||
|
([key, sid]) => window.localStorage.setItem(key, sid),
|
||||||
|
[SESSION_KEY, sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticate(page: Page): Promise<string> {
|
||||||
|
const sessionId = await mintPlayerSession(page);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// ISS-129 (v0.57.0): /api/events pushes full state snapshots over SSE — the current snapshot on
|
||||||
|
// connect, then one after every sim tick (~1 s; the background tick runs even while idle since
|
||||||
|
// v0.56.0, so no scale hook is needed). Reached via the same base URL as playwright.config.ts
|
||||||
|
// (E2E_BASE_URL, defaulting to the live instance) — node-side fetch, no browser involved.
|
||||||
|
const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl';
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
simState: { clockMs: number };
|
||||||
|
packages?: Array<{ status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history
|
||||||
|
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
|
||||||
|
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
|
||||||
|
|
||||||
|
test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
|
||||||
|
// v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client
|
||||||
|
// passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path.
|
||||||
|
const mint = await request.post('/api/test/mint-player-session');
|
||||||
|
expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||||
|
const { sessionId } = (await mint.json()) as { sessionId: string };
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||||
|
expect(res.body).not.toBeNull();
|
||||||
|
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const clocks: number[] = []; // simState.clockMs of each parsed snapshot event
|
||||||
|
const statusesSeen = new Set<string>(); // package statuses observed in any frame (ISS-133)
|
||||||
|
let maxFrameBytes = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Collect two snapshot events: the initial one, then at least one pushed by a background tick.
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (clocks.length < 2 && Date.now() < deadline) {
|
||||||
|
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||||
|
try {
|
||||||
|
chunk = await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('SSE stream stalled')), Math.max(1, deadline - Date.now())),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
break; // stalled or past the deadline — let the length assertion report it
|
||||||
|
}
|
||||||
|
if (chunk.done) break; // stream ended early — same
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
let sep: number;
|
||||||
|
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const frame = buffer.slice(0, sep);
|
||||||
|
buffer = buffer.slice(sep + 2);
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping")
|
||||||
|
const snap = JSON.parse(line.slice('data: '.length)) as StateBody;
|
||||||
|
clocks.push(snap.simState.clockMs);
|
||||||
|
for (const p of snap.packages ?? []) statusesSeen.add(p.status);
|
||||||
|
maxFrameBytes = Math.max(maxFrameBytes, line.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel(); // close the stream → server unsubscribes
|
||||||
|
}
|
||||||
|
|
||||||
|
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
||||||
|
// A grounded fleet freezes the clock (#135), so consecutive frames may carry the SAME clockMs —
|
||||||
|
// non-decreasing is what proves real pushes, not replays (advance itself is pinned in sim.spec.ts).
|
||||||
|
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(clocks[1]).toBeGreaterThanOrEqual(clocks[0]);
|
||||||
|
|
||||||
|
// ISS-133: no terminal package history in any frame (the DB keeps it forever; frames must not),
|
||||||
|
// and a bounded frame size even on an instance with long-running history (~597 KB pre-v0.59.0).
|
||||||
|
for (const status of statusesSeen) {
|
||||||
|
expect(ALLOWED_PACKAGE_STATUSES.has(status)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(maxFrameBytes).toBeLessThan(200_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's
|
||||||
|
// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas.
|
||||||
|
import { haversineKm } from '../lib/geo';
|
||||||
|
import { travelDurationMs } from '../lib/sim/movement';
|
||||||
|
import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog';
|
||||||
|
import { EXPIRY_BUFFER_MS } from '../lib/game/seed';
|
||||||
|
|
||||||
|
// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to
|
||||||
|
// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even
|
||||||
|
// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1)
|
||||||
|
// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on
|
||||||
|
// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or
|
||||||
|
// map queries are involved (the assertion is self-contained in the hook response), so none of those
|
||||||
|
// conventions apply here.
|
||||||
|
|
||||||
|
const FROM = 'EDDB'; // Berlin Brandenburg
|
||||||
|
const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM
|
||||||
|
const COUNT = 10; // priority packages created and checked
|
||||||
|
|
||||||
|
interface Pkg {
|
||||||
|
id: string;
|
||||||
|
originIcao: string;
|
||||||
|
destIcao: string;
|
||||||
|
createdSimTime: number;
|
||||||
|
deadlineSimTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPriorityPackage(page: Page, from: string, to: string): Promise<Pkg> {
|
||||||
|
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()) as Pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => {
|
||||||
|
// The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses.
|
||||||
|
const res = await page.request.get('/api/airports');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === FROM)?.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === TO)?.properties;
|
||||||
|
expect(from, `${FROM} missing from the airport dataset`).toBeTruthy();
|
||||||
|
expect(to, `${TO} missing from the airport dataset`).toBeTruthy();
|
||||||
|
if (!from || !to) return; // guarded above — keeps the math below total
|
||||||
|
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous
|
||||||
|
const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||||
|
|
||||||
|
const created: Pkg[] = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const pkg = await createPriorityPackage(page, FROM, TO);
|
||||||
|
expect(pkg.originIcao).toBe(FROM);
|
||||||
|
expect(pkg.destIcao).toBe(TO);
|
||||||
|
// The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation.
|
||||||
|
// deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly.
|
||||||
|
const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime;
|
||||||
|
expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs));
|
||||||
|
// And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane.
|
||||||
|
expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000);
|
||||||
|
created.push(pkg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Cleanup: age out every package this test created so it leaves no trace on the shared board.
|
||||||
|
for (const pkg of created) {
|
||||||
|
const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
|
||||||
|
expect(r.ok(), `expiring ${pkg.id}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
|
||||||
|
// regardless of time scale or fleet state. The age hook (FLIGHT_TEST_HOOKS=1) stamps a job past its
|
||||||
|
// lifetime and runs a tick, so expiry is observable in one round-trip; self-contained like the test
|
||||||
|
// above (the assertion lives in the hook response, no /api/state polling).
|
||||||
|
test('an open job aged past its wall-clock lifetime expires on the next tick', async ({ page }) => {
|
||||||
|
const pkg = await createPriorityPackage(page, FROM, TO);
|
||||||
|
|
||||||
|
const res = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const aged = (await res.json()) as Pkg & { status: string };
|
||||||
|
expect(aged.id).toBe(pkg.id);
|
||||||
|
expect(aged.status, 'the hook runs a tick after aging the job, so it is expired by response time').toBe('expired');
|
||||||
|
|
||||||
|
// Unknown ids and non-open jobs still 404.
|
||||||
|
const missing = await page.request.post('/api/test/expire-package', { data: { packageId: 'no-such-id' } });
|
||||||
|
expect(missing.status()).toBe(404);
|
||||||
|
const alreadyExpired = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
|
||||||
|
expect(alreadyExpired.status()).toBe(404); // expired is not open — the hook only ages live jobs
|
||||||
|
});
|
||||||
+185
-97
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
|
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
|
||||||
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
|
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
|
||||||
// open-package rows highlight both endpoint airports. The map exposes three top-most
|
// open-package rows highlight both endpoint airports. The map exposes three top-most
|
||||||
@@ -11,10 +16,14 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
|
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
|
||||||
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
|
// Amsterdam — a third airport, distinct from hub and destination (the queued stop in the ISS-077 test).
|
||||||
|
const QUEUE = 'EHAM';
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,35 +48,34 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -79,34 +87,38 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// 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 (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an
|
||||||
|
// interrupted run, 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
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
// active repair gate from an interrupted run before acting on the starter.
|
||||||
|
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 starter = s.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×).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
}
|
}
|
||||||
if (starter.status === 'flying') {
|
if (starter.status === 'flying') {
|
||||||
await expect
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||||||
timeout: 240_000,
|
s = await getState(page);
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
starter = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
|
||||||
timeout: 240_000,
|
|
||||||
message: 'waiting for plane-starter to land back at the hub',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +168,8 @@ async function unhover(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
|
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
|
||||||
// features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves
|
// features rendered inside the CURRENT viewport, and the v0.70.0 default world view ([0,0] z2) leaves
|
||||||
// far endpoints (LEMD) off-screen even when they match a layer filter.
|
// far endpoints (e.g. NFTF) off-screen even when they match a layer filter.
|
||||||
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||||
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 } }>;
|
||||||
@@ -167,6 +179,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
|||||||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
return [f.properties.lon, f.properties.lat] as [number, number];
|
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||||
});
|
});
|
||||||
|
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
|
||||||
|
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
|
||||||
|
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
let lon = points[i]![0];
|
||||||
|
const prev = points[i - 1]![0];
|
||||||
|
while (lon - prev > 180) lon -= 360;
|
||||||
|
while (prev - lon > 180) lon += 360;
|
||||||
|
points[i]![0] = lon;
|
||||||
|
}
|
||||||
await page.evaluate((pts: [number, number][]) => {
|
await page.evaluate((pts: [number, number][]) => {
|
||||||
const m = (window as any).__flightMap;
|
const m = (window as any).__flightMap;
|
||||||
if (!m) throw new Error('map not ready');
|
if (!m) throw new Error('map not ready');
|
||||||
@@ -213,8 +235,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any
|
|||||||
|
|
||||||
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
||||||
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
|
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
|
||||||
const near = (a: [number, number], b: [number, number]) =>
|
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
|
||||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
// (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
|
||||||
|
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
|
||||||
|
const near = (a: [number, number], b: [number, number]) => {
|
||||||
|
const dLon = Math.abs(a[0] - b[0]);
|
||||||
|
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||||
|
};
|
||||||
await expect.poll(async () => {
|
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][] } }>;
|
||||||
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
|
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
|
||||||
@@ -300,8 +327,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
|
|||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen
|
// Both endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
// at the default Frankfurt-centered view).
|
|
||||||
await fitToAirports(page, [HUB, DEST]);
|
await fitToAirports(page, [HUB, DEST]);
|
||||||
|
|
||||||
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
|
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
|
||||||
@@ -340,6 +366,57 @@ test('hovering an open package highlights its endpoint airports', async ({ page
|
|||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
|
||||||
|
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
|
||||||
|
// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a
|
||||||
|
// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to
|
||||||
|
// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at
|
||||||
|
// the hub; same crossing, no relocation needed.
|
||||||
|
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
const pkg = await createPackage(page, HUB, 'NFTF');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
|
||||||
|
// ~176° of longitude — fitting it needs z≈1, below the old MIN_ZOOM=3 floor; since v0.70.0 minZoom is
|
||||||
|
// natively 0 (components/Map.tsx), no setMinZoom workaround is needed anymore. Quantization stays
|
||||||
|
// ≤~0.02° even near z=0, below EPS (:214–228).
|
||||||
|
await fitToAirports(page, [HUB, 'NFTF']);
|
||||||
|
|
||||||
|
await row.hover();
|
||||||
|
await expectHoverRoute(page, HUB, 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
|
||||||
|
|
||||||
|
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||||
|
const segs = feats.filter(
|
||||||
|
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||||
|
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||||
|
);
|
||||||
|
if (!segs.length) return false; // none yet — keep polling
|
||||||
|
for (const s of segs) {
|
||||||
|
const c = s.geometry.coordinates;
|
||||||
|
for (let i = 1; i < c.length; i++) {
|
||||||
|
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true);
|
||||||
|
|
||||||
|
await unhover(page);
|
||||||
|
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||||
|
timeout: 15_000,
|
||||||
|
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||||
|
}).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
|
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
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
|
||||||
@@ -439,33 +516,36 @@ test('hovering the Relocate section previews the planned route and target airpor
|
|||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
|
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST 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.
|
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
|
||||||
|
// post-action snapshot already shows the plane flying — no poll race.
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
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 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);
|
const flyBody = (await fly.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
const block = page.getByTestId('onboard-plane-starter');
|
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
|
||||||
|
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
|
||||||
|
// test below).
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
|
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||||
|
const block = page.getByTestId('plane-plane-starter');
|
||||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
|
||||||
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused.
|
|
||||||
await postSim(page, { paused: true });
|
|
||||||
|
|
||||||
await block.hover();
|
await block.hover();
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||||||
@@ -478,9 +558,10 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
message: 'waiting for the highlight to clear after mouseleave',
|
message: 'waiting for the highlight to clear after mouseleave',
|
||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
// Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
// stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
// arrivals → FAST (~1 s each instead of ~10 s).
|
||||||
|
await setScale(page, FAST);
|
||||||
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');
|
||||||
@@ -488,8 +569,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
@@ -500,55 +581,52 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
.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 (frozen mid-leg) + FAST 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. At
|
||||||
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
|
||||||
const hubLonLat = await airportLonLat(page, HUB);
|
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
|
||||||
await expect
|
await setScale(page, 1);
|
||||||
.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 });
|
|
||||||
|
|
||||||
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 +636,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 +651,13 @@ 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);
|
||||||
|
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||||
|
await setScale(page, FAST);
|
||||||
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,8 +665,11 @@ 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);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
await setScale(page, SCALE);
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
|
// Re-set FAST for the return leg (~1 s instead of ~10 s).
|
||||||
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
|
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||||
|
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||||
|
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||||
|
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
|
||||||
|
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
||||||
|
// passes.
|
||||||
|
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
maxFuel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
|
planes: PlaneState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
|
// a silent reset would otherwise surface as an unexplained wait timeout.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||||
|
async function setHealth(
|
||||||
|
page: Page,
|
||||||
|
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||||
|
): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||||
|
nearest = f.properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||||
|
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||||
|
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
if (starter.status === 'repairing') {
|
||||||
|
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||||
|
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying') {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.locationIcao !== HUB) {
|
||||||
|
// Make sure the world rate is back at SCALE for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
|
// leg when cash allows — no top-up retry needed.
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||||
|
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||||
|
async function restoreStarter(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||||
|
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||||
|
await setHealth(page, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// On exit (success or failure): restore the fixed world rate for the other specs (house convention)
|
||||||
|
// and leave a clean starter — full health, no active repair gate.
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
await restoreStarter(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
|
||||||
|
|
||||||
|
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// (a) Force health to 42 — snapshot AND UI show it.
|
||||||
|
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.getByTestId(`plane-${STARTER}`);
|
||||||
|
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||||
|
.toContainText('42%');
|
||||||
|
|
||||||
|
// (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
|
||||||
|
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||||
|
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||||
|
.toHaveText('broken', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
|
||||||
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
|
||||||
|
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
|
||||||
|
|
||||||
|
s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.status).toBe('available'); // still grounded…
|
||||||
|
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
|
||||||
|
|
||||||
|
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
|
||||||
|
const cashBefore = s.account.cash;
|
||||||
|
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||||
|
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||||
|
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||||
|
// checked against the exact game clock the server used (a later GET would tick further).
|
||||||
|
const [res] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||||
|
repairBtn.click(),
|
||||||
|
]);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||||
|
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(repaired.status).toBe('repairing');
|
||||||
|
expect(repaired.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
|
||||||
|
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||||
|
.toHaveText('in repair', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||||
|
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||||
|
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.health === 100 ? true : false;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
+327
-39
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||||
// deterministic generated weather), plane markers whose positions come from the
|
// deterministic generated weather), plane markers whose positions come from the
|
||||||
@@ -14,7 +19,11 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
|||||||
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
|
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
|
||||||
// the 6300 L tank, so a full-tank departure always makes it home.
|
// the 6300 L tank, so a full-tank departure always makes it home.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top)
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish
|
||||||
|
// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate.
|
||||||
|
const FAST = 50_000;
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,7 +49,7 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
@@ -59,29 +68,28 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -93,15 +101,23 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
// 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 fast-forward at FAST
|
||||||
|
// (pure arrival wait — no mid-leg observation) 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> {
|
||||||
const pkg = await createPackage(page, fromIcao, toIcao);
|
const pkg = await createPackage(page, fromIcao, toIcao);
|
||||||
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 fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
@@ -112,7 +128,8 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
|
|||||||
return pkg;
|
return pkg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over
|
||||||
|
// the area of interest first ([] until then). Same trap as run-176: default world view + Pacific plane = [].
|
||||||
function sourceFeatures(page: Page, sourceId: string) {
|
function sourceFeatures(page: Page, sourceId: string) {
|
||||||
return page.evaluate((srcId: string) => {
|
return page.evaluate((srcId: string) => {
|
||||||
const m = (window as any).__flightMap;
|
const m = (window as any).__flightMap;
|
||||||
@@ -146,7 +163,23 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts.
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view
|
||||||
|
// ([0,0] z2) shows both Africa + Europe with overlapping markers (nearest neighbour LFRB Brest is
|
||||||
|
// only ~13–19 px away at z2), so frame LFRS at z5 first — its nearest marker is then ~104 px away.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__flightMap.jumpTo({ center: [-1.61073, 47.153198], zoom: 5 });
|
||||||
|
});
|
||||||
|
// Wait until LFRS's marker is actually rendered at the new camera before clicking it.
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([-1.61073, 47.153198]);
|
||||||
|
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
|
||||||
|
.some((f) => f.properties?.icao === 'LFRS');
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
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
|
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
|
||||||
@@ -174,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
|
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup
|
test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -185,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||||
expect(foundStarter).toBeDefined();
|
expect(foundStarter).toBeDefined();
|
||||||
if (foundStarter!.status === 'flying') {
|
if (foundStarter!.status === 'flying') {
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -198,10 +231,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);
|
||||||
@@ -229,7 +260,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -252,23 +283,35 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
|
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
|
||||||
|
|
||||||
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
|
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
|
||||||
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered
|
// Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
|
||||||
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift
|
// our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
|
||||||
// on this leg. Pausing makes the position static; we resume right after the popup checks.
|
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
|
||||||
await postSim(page, { paused: true });
|
// we restore SCALE right after the popup checks.
|
||||||
|
await setScale(page, 1);
|
||||||
|
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
|
||||||
|
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
|
||||||
|
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
|
||||||
|
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
|
||||||
|
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
|
||||||
|
// would show (~km per poll pair).
|
||||||
let prevPos: { lat: number; lon: number } | null = null;
|
let prevPos: { lat: number; lon: number } | null = null;
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
if (!p || p.status !== 'flying') return false;
|
if (!p || p.status !== 'flying') return false;
|
||||||
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
|
if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
|
||||||
prevPos = { lat: p.lat, lon: p.lon };
|
prevPos = { lat: p.lat, lon: p.lon };
|
||||||
return false;
|
return false;
|
||||||
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' })
|
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused
|
// queryRenderedFeatures is viewport-scoped, and at the v0.70.0 default view ([0,0] z2) this pane
|
||||||
// above the server's analytic position is static — but the map renders from its own ~1 s
|
// only shows latitudes ~±51° — EDDF (50°N) sits right at its edge, so frame both endpoints of the
|
||||||
|
// leg to guarantee the plane marker is rendered wherever it is on it.
|
||||||
|
await fitToAirports(page, [HUB, DEST]);
|
||||||
|
|
||||||
|
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
|
||||||
|
// above the server's analytic position barely moves — but the map renders from its own ~1 s
|
||||||
// poll, so right after the freeze confirmation the last rendered frame can still be up to
|
// poll, so right after the freeze confirmation the last rendered frame can still be up to
|
||||||
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
|
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
|
||||||
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
|
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
|
||||||
@@ -308,8 +351,8 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// The popup's route line is the plane's own leg: origin name → destination name.
|
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||||
|
|
||||||
// Resume before step 5 — the landing wait needs a running clock.
|
// Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s.
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
await setScale(page, FAST);
|
||||||
|
|
||||||
// 5) Let the flight finish, then fly home so the live instance stays in its
|
// 5) Let the flight finish, then fly home so the live instance stays in its
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||||
@@ -324,3 +367,248 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
|
|
||||||
await loadAndFly(page, DEST, HUB);
|
await loadAndFly(page, DEST, HUB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ISS-124 (v0.58.0): a marker on a leg crossing the antimeridian must not teleport — its longitude is
|
||||||
|
// unwrapped against the departure airport in planesGeoJson (lib/mapData.ts), so consecutive polls stay
|
||||||
|
// continuous exactly like the route line. The starter can't fly EDDF→NZAA in one leg (~19,000 km vs the
|
||||||
|
// ATR72's ~3,700 km guaranteed range), so relocation is a fewest-legs chain over /api/airports with each
|
||||||
|
// hop ≤ MAX_LEG_KM (within that range even in worst-case headwind); set-fuel tops the tank for free before
|
||||||
|
// every dispatch so auto-refuel costs nothing.
|
||||||
|
const MAX_LEG_KM = 3400;
|
||||||
|
|
||||||
|
// Fewest-legs chain from → to over /api/airports, each hop ≤ maxLegKm — BFS with adjacency sorted by
|
||||||
|
// (distance, ICAO) so the chain is deterministic for a given dataset. Excludes `from`, includes `to`.
|
||||||
|
async function chainTo(page: Page, fromIcao: string, toIcao: string): Promise<string[]> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const aps = fc.features.map((f) => f.properties);
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const havKm = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => {
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLon = toRad(b.lon - a.lon);
|
||||||
|
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||||
|
};
|
||||||
|
const idx = new Map(aps.map((a, i) => [a.icao, i]));
|
||||||
|
const adj: Array<Array<[number, number]>> = aps.map(() => []);
|
||||||
|
for (let i = 0; i < aps.length; i++) {
|
||||||
|
for (let j = i + 1; j < aps.length; j++) {
|
||||||
|
const d = havKm(aps[i]!, aps[j]!);
|
||||||
|
if (d <= MAX_LEG_KM) {
|
||||||
|
adj[i]!.push([j, d]);
|
||||||
|
adj[j]!.push([i, d]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const a of adj) a.sort((x, y) => x[1] - y[1] || aps[x[0]]!.icao.localeCompare(aps[y[0]]!.icao));
|
||||||
|
const s = idx.get(fromIcao)!;
|
||||||
|
const t = idx.get(toIcao)!;
|
||||||
|
const prev = new Array<number>(aps.length).fill(-1);
|
||||||
|
const queue: number[] = [s];
|
||||||
|
for (let qi = 0; qi < queue.length && prev[t] === -1; qi++) {
|
||||||
|
const u = queue[qi]!;
|
||||||
|
for (const [v] of adj[u]!) {
|
||||||
|
if (prev[v] === -1) {
|
||||||
|
prev[v] = u;
|
||||||
|
queue.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(prev[t], `no ≤${MAX_LEG_KM} km chain from ${fromIcao} to ${toIcao}`).not.toBe(-1);
|
||||||
|
const path: string[] = [];
|
||||||
|
for (let cur = t; cur !== s; cur = prev[cur]!) path.unshift(aps[cur]!.icao);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plane-starter's marker from the planes source: its position plus whether that frame is a FLYING one.
|
||||||
|
// Extracted IN-PAGE because MapLibre feature geometry can be a lazy prototype getter that Playwright's
|
||||||
|
// evaluate serialization drops (same trap as e2e/hover.spec.ts). The `flying` flag matters for the
|
||||||
|
// sampling loop: a grounded frame snaps back to the raw airport coordinate (−175.1), which is the same
|
||||||
|
// screen point but a 360° jump in raw data — only flying frames may be recorded. `lat` feeds the
|
||||||
|
// freshness gate below (a stale source frame sits far from where the SERVER says the plane is).
|
||||||
|
function starterMarker(page: Page): Promise<{ lat: number; lon: number; flying: boolean } | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m || !m.getSource('planes')) return null;
|
||||||
|
try {
|
||||||
|
for (const f of m.querySourceFeatures('planes') as Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }>) {
|
||||||
|
if (f.properties?.id === 'plane-starter' && f.geometry) {
|
||||||
|
return { lat: f.geometry.coordinates[1]!, lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// map/source not ready yet — null keeps the caller polling
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump the camera to a box around the given airports. Copied from e2e/hover.spec.ts — that version
|
||||||
|
// unwraps crossing pairs (ISS-120) so a ±180-straddling pair fits the Pacific, not the Atlantic.
|
||||||
|
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const points = icaos.map((icao) => {
|
||||||
|
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||||
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
|
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||||
|
});
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
let lon = points[i]![0];
|
||||||
|
const prev = points[i - 1]![0];
|
||||||
|
while (lon - prev > 180) lon -= 360;
|
||||||
|
while (prev - lon > 180) lon += 360;
|
||||||
|
points[i]![0] = lon;
|
||||||
|
}
|
||||||
|
await page.evaluate((pts: [number, number][]) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) throw new Error('map not ready');
|
||||||
|
const lons = pts.map((p) => p[0]);
|
||||||
|
const lats = pts.map((p) => p[1]);
|
||||||
|
// duration 0 → instant camera jump, no animation to wait out.
|
||||||
|
m.fitBounds(
|
||||||
|
[
|
||||||
|
[Math.min(...lons), Math.min(...lats)],
|
||||||
|
[Math.max(...lons), Math.max(...lats)],
|
||||||
|
],
|
||||||
|
{ padding: 40, duration: 0 },
|
||||||
|
);
|
||||||
|
}, points);
|
||||||
|
// Give the renderer a beat after the camera jump before asserting on rendered features.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
|
||||||
|
test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
const ORIGIN = 'NZAA'; // Auckland — east of +170
|
||||||
|
const CROSS_DEST = 'NFTF'; // Fua'amotu (Tonga) — west of −175; the short arc crosses ±180 EASTWARD
|
||||||
|
|
||||||
|
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
|
||||||
|
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||||
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||||||
|
}
|
||||||
|
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
|
||||||
|
// grounded at HUB so no later spec's fly-home gets a 409 (the run-176 pollution cascade).
|
||||||
|
try {
|
||||||
|
let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||||
|
if (loc !== ORIGIN) {
|
||||||
|
for (const next of await chainTo(page, loc, ORIGIN)) {
|
||||||
|
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||||
|
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||||
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh page before the crossing leg: this test's page has been alive ~3 min (relocation chain) and
|
||||||
|
// under dind load the browser's state feed can go stale — a frozen planes source would then keep an
|
||||||
|
// old FLYING frame from a relocation hop, fooling the flying poll below and sampling stale lons.
|
||||||
|
// Server state persists across reloads; the plane is grounded at ORIGIN here, so on this fresh page
|
||||||
|
// ONLY the crossing leg can ever produce a flying frame.
|
||||||
|
await page.reload();
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// The crossing package + dispatch. RUN-179 DATA COLLECTION: server-side truth is logged at every
|
||||||
|
// phase (unconditionally — Playwright puts test stdout in the job log for failed tests), so a failed
|
||||||
|
// run shows exactly what /api/state said while the browser source was sampled.
|
||||||
|
const fmtPlane = (p: PlaneState) => `status=${p.status} loc=${p.locationIcao} dest=${p.destIcao ?? '-'} lat=${p.lat.toFixed(4)} lon=${p.lon.toFixed(4)}`;
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
|
||||||
|
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||||||
|
console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||||
|
// The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the
|
||||||
|
// >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST
|
||||||
|
// rate would let the dispatch tick itself consume most of the leg before this setScale lands.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
|
||||||
|
if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`);
|
||||||
|
console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||||
|
|
||||||
|
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
|
||||||
|
// Pacific leg: fit the camera over both endpoints first or the plane feature never appears in queries.
|
||||||
|
await fitToAirports(page, [ORIGIN, CROSS_DEST]);
|
||||||
|
|
||||||
|
// Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND
|
||||||
|
// the planes source agrees. A marker-only poll could pass on a stale FLYING frame from an earlier hop;
|
||||||
|
// sampling must start only when this leg is actually underway server-side.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
if (!(p.status === 'flying' && p.locationIcao === ORIGIN && p.destIcao === CROSS_DEST)) return false;
|
||||||
|
return (await starterMarker(page))?.flying ?? false;
|
||||||
|
}, { timeout: 30_000, message: `server must show plane-starter flying ${ORIGIN}->${CROSS_DEST} AND the planes source must show it flying` }).toBe(true);
|
||||||
|
console.log(`[antimeridian] FLYING first observed (server): ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||||
|
|
||||||
|
// Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only
|
||||||
|
// FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen
|
||||||
|
// point but a 360° jump in raw data, and could land between this loop's server read and source read.
|
||||||
|
// Freshness gate: the browser's source may lag server truth by a tick or two (SSE + tile re-render),
|
||||||
|
// but a FROZEN frame would otherwise pass continuity vacuously and never show the crossing — accept
|
||||||
|
// only frames within ~700 km of where the SERVER says the plane is (~5 s of flight at ×1000); haversine
|
||||||
|
// is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly.
|
||||||
|
const lons: number[] = [];
|
||||||
|
const lats: number[] = [];
|
||||||
|
let rawSamples = 0;
|
||||||
|
const rejected: string[] = [];
|
||||||
|
const acceptedDetail: string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
if (p.status !== 'flying') break;
|
||||||
|
rawSamples++;
|
||||||
|
const marker = await starterMarker(page);
|
||||||
|
if (!marker || !marker.flying) {
|
||||||
|
rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker=${marker ? `not-flying (${marker.lat.toFixed(4)},${marker.lon.toFixed(4)})` : 'null'}`);
|
||||||
|
} else {
|
||||||
|
const d = haversineKm(p.lat, p.lon, marker.lat, marker.lon);
|
||||||
|
if (d < 700) {
|
||||||
|
lons.push(marker.lon);
|
||||||
|
lats.push(marker.lat);
|
||||||
|
// Δlon ≈ 0 → source tracks the wrapped server lon (unwrap NOT applied or pre-crossing);
|
||||||
|
// Δlon ≈ +360 post-crossing → unwrap IS applied to what starterMarker reads.
|
||||||
|
acceptedDetail.push(`server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) source(lon=${marker.lon.toFixed(4)} lat=${marker.lat.toFixed(4)}) Δlon=${(marker.lon - p.lon).toFixed(2)}`);
|
||||||
|
} else {
|
||||||
|
rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker(${marker.lat.toFixed(4)},${marker.lon.toFixed(4)}) dist=${d.toFixed(1)}km ≥700`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditional diagnostics (run-179): full arrays + raw/accepted/rejected counts, so the failure log
|
||||||
|
// maps directly to a cause: constant ≈174.8 → frozen pre-crossing frame; values below −180 → westward
|
||||||
|
// unwrap; > 180 with Δlon≈+360 → correct eastward unwrap; empty after gate → source never fresh.
|
||||||
|
console.log(`[antimeridian] sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`);
|
||||||
|
console.log(`[antimeridian] sampled lats (n=${lats.length}): ${JSON.stringify(lats)}`);
|
||||||
|
console.log(`[antimeridian] raw samples=${rawSamples} accepted=${lons.length} rejected=${rejected.length}`);
|
||||||
|
for (const r of rejected) console.log(`[antimeridian] REJECTED: ${r}`);
|
||||||
|
const detail = acceptedDetail.length <= 10 ? acceptedDetail : [...acceptedDetail.slice(0, 5), '…', ...acceptedDetail.slice(-5)];
|
||||||
|
for (const a of detail) console.log(`[antimeridian] ACCEPTED: ${a}`);
|
||||||
|
|
||||||
|
expect(lons.length, `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}; rejected: ${rejected.join(' | ')}`).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), not a degenerate one
|
||||||
|
for (let i = 1; i < lons.length; i++) {
|
||||||
|
expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug
|
||||||
|
}
|
||||||
|
expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180
|
||||||
|
expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window
|
||||||
|
} finally {
|
||||||
|
// Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other
|
||||||
|
// specs start from the canonical state. The package was delivered on arrival (generous deadline).
|
||||||
|
await setScale(page, FAST);
|
||||||
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||||||
|
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||||
|
if (endLoc !== HUB) {
|
||||||
|
for (const next of await chainTo(page, endLoc, HUB)) {
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||||
|
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||||
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+81
-19
@@ -2,15 +2,43 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
const APP_URL = '/';
|
const APP_URL = '/';
|
||||||
|
|
||||||
// Read at test time so the assertion tracks package.json automatically.
|
// Read at test time so the assertion tracks package.json automatically.
|
||||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
|
||||||
|
|
||||||
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
|
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
|
||||||
const MIN_ZOOM = 3;
|
const MIN_ZOOM = 0; // v0.70.0: lowered 3 → 0 so the whole earth is visible when fully zoomed out
|
||||||
const MAX_ZOOM = 11;
|
const MAX_ZOOM = 11;
|
||||||
|
|
||||||
|
// LFRS Nantes Atlantique — the marker-click target of this file (and e2e/map-interactions.spec.ts,
|
||||||
|
// e2e/relocation.spec.ts). The v0.70.0 default view ([0,0] z2) shows both Africa + Europe with
|
||||||
|
// overlapping markers (nearest neighbour LFRB Brest is only ~13–19 px away at z2), so every click
|
||||||
|
// test first frames LFRS at z5 — its nearest marker is then ~104 px away and the projected center
|
||||||
|
// cannot hit a neighbouring circle.
|
||||||
|
const LFRS: [number, number] = [-1.61073, 47.153198];
|
||||||
|
|
||||||
|
async function frameLfrs(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((c) => (window as any).__flightMap.jumpTo({ center: c, zoom: 5 }), LFRS);
|
||||||
|
// Wait until LFRS's marker is actually rendered at the new camera before clicking it. The
|
||||||
|
// coordinate is passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope
|
||||||
|
// values don't exist (same trap as e2e/relocation.spec.ts).
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate((c) => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project(c);
|
||||||
|
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
|
||||||
|
.some((f) => f.properties?.icao === 'LFRS');
|
||||||
|
}, LFRS),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForAirportCount(page: Page): Promise<number> {
|
async function waitForAirportCount(page: Page): Promise<number> {
|
||||||
const countEl = page.getByTestId('airport-count');
|
const countEl = page.getByTestId('airport-count');
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
@@ -22,17 +50,43 @@ 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);
|
||||||
expect(count).toBeGreaterThan(0);
|
expect(count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// v0.70.0 default view: null island [0,0] at zoom 2 (Africa + Europe both visible). The map
|
||||||
|
// instance exists by the time the airport count renders — it is created before airports load.
|
||||||
|
const view = await page.evaluate(() => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
return { lng: m.getCenter().lng, lat: m.getCenter().lat, z: m.getZoom() };
|
||||||
|
});
|
||||||
|
expect(view.z).toBeCloseTo(2);
|
||||||
|
expect(view.lng).toBeCloseTo(0);
|
||||||
|
expect(view.lat).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('header shows the app version sourced from package.json', async ({ page }) => {
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button
|
||||||
|
// (mirrors e2e/admin.spec.ts' logout assertion).
|
||||||
|
test('top bar Log out drops back to the sign-in screen', async ({ page }) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
const header = page.getByTestId('app-header');
|
||||||
|
await expect(header).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId('logout-btn').click();
|
||||||
|
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(header).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 }) => {
|
||||||
@@ -51,18 +105,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
|
// Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why
|
||||||
// Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
|
// the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
|
||||||
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
|
await frameLfrs(page);
|
||||||
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
|
const pt = await page.evaluate((c) => {
|
||||||
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
|
|
||||||
// be overlapped by a neighbouring circle.
|
|
||||||
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(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
|
||||||
const rect = map.getContainer().getBoundingClientRect();
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
return { x: rect.left + p.x, y: rect.top + p.y };
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
});
|
}, LFRS);
|
||||||
await page.mouse.click(pt.x, pt.y);
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
const popup = page.getByTestId('airport-popup');
|
const popup = page.getByTestId('airport-popup');
|
||||||
@@ -133,12 +184,18 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
|
|||||||
await waitForStableZoom();
|
await waitForStableZoom();
|
||||||
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
|
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
|
||||||
|
|
||||||
// Zoom out well below MIN_ZOOM with several large deltas.
|
// Zoom out to the effective minimum. MapLibre won't zoom out past the point where
|
||||||
await jumpTo(MIN_ZOOM + 1);
|
// the world no longer covers the viewport height (fit floor = log2(h/512)), so with
|
||||||
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9);
|
// MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see
|
||||||
|
// the whole Earth", which is exactly what min zoom should give.
|
||||||
|
const mapBox = await page.getByTestId('map').boundingBox();
|
||||||
|
if (!mapBox) throw new Error('map bounding box unavailable');
|
||||||
|
const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512));
|
||||||
|
await jumpTo(minLimit + 1);
|
||||||
|
await wheelToLimit(600, (z) => z <= minLimit + 1e-9);
|
||||||
await waitForStableZoom();
|
await waitForStableZoom();
|
||||||
const zoom = await getZoom();
|
const zoom = await getZoom();
|
||||||
expect(zoom).toBeCloseTo(MIN_ZOOM);
|
expect(zoom).toBeCloseTo(minLimit);
|
||||||
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
|
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
|
||||||
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
|
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
|
||||||
});
|
});
|
||||||
@@ -181,9 +238,12 @@ 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);
|
||||||
|
|
||||||
|
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
|
||||||
|
await frameLfrs(page);
|
||||||
|
|
||||||
// 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
|
||||||
// re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
|
// re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
|
||||||
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
|
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
|
||||||
@@ -253,12 +313,14 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
const pt = await page.evaluate(() => {
|
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
|
||||||
|
await frameLfrs(page);
|
||||||
|
const pt = await page.evaluate((c) => {
|
||||||
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(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
|
||||||
const rect = map.getContainer().getBoundingClientRect();
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
return { x: rect.left + p.x, y: rect.top + p.y };
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
});
|
}, LFRS);
|
||||||
await page.mouse.click(pt.x, pt.y);
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
const popup = page.getByTestId('airport-popup');
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||||||
|
|
||||||
|
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||||||
|
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||||||
|
// player; player B is a second account minted through the same hook with a username override (the
|
||||||
|
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||||||
|
//
|
||||||
|
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||||||
|
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||||||
|
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||||||
|
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||||||
|
|
||||||
|
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
|
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||||||
|
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
locationIcao: string;
|
||||||
|
maxFuel: number;
|
||||||
|
queue: string[];
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
cash: number;
|
||||||
|
reputation: number;
|
||||||
|
planeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number };
|
||||||
|
planes: PlaneState[];
|
||||||
|
players: PlayerSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
|
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||||
|
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||||
|
if (q.length === 0) return;
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||||||
|
async function nearestToHub(page: Page): Promise<string> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
return fc.features
|
||||||
|
.filter((f) => f.properties.icao !== HUB)
|
||||||
|
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||||
|
.sort((x, y) => x.d - y.d)[0].icao;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||||||
|
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
return { ctx, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
await clearQueueViaApi(page, STARTER);
|
||||||
|
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||||||
|
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||||||
|
if (p.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight starter to land during cleanup',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (p.locationIcao !== HUB) {
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the starter to land back at the hub during cleanup',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||||||
|
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||||||
|
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for the fleet to be fully grounded',
|
||||||
|
})
|
||||||
|
.toBe('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||||||
|
|
||||||
|
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
try {
|
||||||
|
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||||||
|
await healStarterHome(page);
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
} finally {
|
||||||
|
if (bCtx) await bCtx.close();
|
||||||
|
bCtx = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||||||
|
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||||||
|
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
try {
|
||||||
|
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||||||
|
// Verify through the same surface the game uses — mint a local session just for this read.
|
||||||
|
const sessionId = await mintPlayerSession(page);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
const s = (await getState(page)) as StateBody;
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||||||
|
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||||||
|
|
||||||
|
const dest = await nearestToHub(page);
|
||||||
|
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||||||
|
await setFuelFull(page, STARTER);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||||||
|
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||||||
|
let prev = (await getState(page)).simState.clockMs;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||||||
|
const now = s.simState.clockMs;
|
||||||
|
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: "waiting for A's plane to land",
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
|
||||||
|
const frozen1 = await getState(page);
|
||||||
|
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||||||
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||||||
|
const frozen2 = await getState(page);
|
||||||
|
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
|
||||||
|
const sA = await getState(page); // A's (local session's) view of the shared world
|
||||||
|
const sB = await getState(b.page); // B's view — same world…
|
||||||
|
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||||||
|
|
||||||
|
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||||||
|
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.ownerId).toBe('local');
|
||||||
|
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||||||
|
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||||||
|
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||||||
|
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||||||
|
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||||||
|
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||||||
|
|
||||||
|
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||||||
|
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||||||
|
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyForeign.status()).toBe(403);
|
||||||
|
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||||||
|
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||||||
|
expect(sellForeign.status()).toBe(403);
|
||||||
|
|
||||||
|
// …and B cannot act on A's.
|
||||||
|
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyA.status()).toBe(403);
|
||||||
|
|
||||||
|
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||||||
|
// so nothing else can touch either economy between reads.
|
||||||
|
const cA = sA.account.cash;
|
||||||
|
const cB = sB.account.cash;
|
||||||
|
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||||||
|
expect(grant.status()).toBe(200);
|
||||||
|
const granted = ((await grant.json()) as StateBody).account.cash;
|
||||||
|
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||||||
|
const sBAfter = await getState(b.page);
|
||||||
|
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||||||
|
|
||||||
|
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||||||
|
for (const s of [sA, sBAfter]) {
|
||||||
|
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
+139
-120
@@ -1,35 +1,100 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
||||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
|
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||||
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
|
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
|
||||||
interface SimState {
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
clockMs: number;
|
const STARTER = 'plane-starter';
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
|
||||||
|
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
|
||||||
|
// and repairPlane 409s on non-available planes, which would break activateWorld.
|
||||||
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before acting on the starter.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||||
|
let plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
|
||||||
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (plane.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
|
||||||
|
}
|
||||||
|
if (plane.locationIcao !== HUB) {
|
||||||
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const res = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
|
||||||
// pattern as e2e/sim.spec.ts.
|
// pattern as e2e/sim.spec.ts.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
||||||
@@ -70,119 +135,60 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
// Sim clock renders (UTC wall-clock of the game time).
|
// Sim clock renders (UTC wall-clock of the game time).
|
||||||
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
|
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
|
||||||
// its heading renders here…
|
// per-plane cargo rows now render inside the fleet rows.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
// …and no longer in the right pane.
|
|
||||||
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);
|
|
||||||
|
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
|
||||||
|
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
|
||||||
await page.goto('/');
|
test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
|
||||||
const pauseBtn = page.getByTestId('pause-toggle');
|
// Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
|
||||||
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
|
// 409s on non-available planes — it must be grounded before activateWorld.
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
||||||
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for full scale.
|
||||||
|
await activateWorld(page);
|
||||||
|
try {
|
||||||
|
await page.goto('/');
|
||||||
|
const clock = page.getByTestId('sim-clock');
|
||||||
|
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Pause via the UI button — assert against the authoritative /api/state…
|
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
|
||||||
await pauseBtn.click();
|
const sample = async () => {
|
||||||
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
|
const text = ((await clock.textContent()) ?? '').trim();
|
||||||
// …and in the pane itself (the refetch after POST flips the button to Resume).
|
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||||
await expect(pauseBtn).toHaveText('Resume');
|
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||||
|
};
|
||||||
|
|
||||||
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
|
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
|
||||||
// optimistically on input, and release flushes the debounced POST immediately.
|
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
|
||||||
const slider = page.getByTestId('time-scale-slider');
|
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||||
await slider.focus();
|
const t0 = await sample();
|
||||||
await page.keyboard.press('End');
|
await new Promise((r) => setTimeout(r, 1_300));
|
||||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
|
const t1 = await sample();
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
await new Promise((r) => setTimeout(r, 1_300));
|
||||||
|
const t2 = await sample();
|
||||||
|
|
||||||
// Home jumps to min (t=0 → 1×).
|
expect(t1).toBeGreaterThan(t0);
|
||||||
await page.keyboard.press('Home');
|
expect(t2).toBeGreaterThan(t1);
|
||||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
|
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
// still catching a frozen or wildly mis-scaled clock.
|
||||||
|
const total = t2 - t0;
|
||||||
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
|
expect(total).toBeGreaterThanOrEqual(50_000);
|
||||||
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
|
expect(total).toBeLessThanOrEqual(200_000);
|
||||||
|
} finally {
|
||||||
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
|
await deactivateWorld(page);
|
||||||
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
|
||||||
expect(restore.status()).toBe(200);
|
|
||||||
const after = await getState(page);
|
|
||||||
expect(after.simState.timeScale).toBe(1);
|
|
||||||
expect(after.simState.paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const clock = page.getByTestId('sim-clock');
|
|
||||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Fastest scale so game seconds move visibly inside the sampling window.
|
|
||||||
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
|
|
||||||
expect(post.status()).toBe(200);
|
|
||||||
|
|
||||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
|
|
||||||
const sample = async () => {
|
|
||||||
const text = ((await clock.textContent()) ?? '').trim();
|
|
||||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
|
||||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
|
|
||||||
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
|
|
||||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
|
||||||
const t0 = await sample();
|
|
||||||
await new Promise((r) => setTimeout(r, 1_300));
|
|
||||||
const t1 = await sample();
|
|
||||||
await new Promise((r) => setTimeout(r, 1_300));
|
|
||||||
const t2 = await sample();
|
|
||||||
|
|
||||||
expect(t1).toBeGreaterThan(t0);
|
|
||||||
expect(t2).toBeGreaterThan(t1);
|
|
||||||
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
|
|
||||||
// still catching a frozen or wildly mis-scaled clock.
|
|
||||||
const total = t2 - t0;
|
|
||||||
expect(total).toBeGreaterThanOrEqual(1_000_000);
|
|
||||||
expect(total).toBeLessThanOrEqual(6_000_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
|
|
||||||
test.setTimeout(60_000);
|
|
||||||
await page.goto('/');
|
|
||||||
const slider = page.getByTestId('time-scale-slider');
|
|
||||||
await expect(slider).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Commit to max via keyboard (input + change → immediate commitNow POST)…
|
|
||||||
await slider.focus();
|
|
||||||
await page.keyboard.press('End');
|
|
||||||
|
|
||||||
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
|
|
||||||
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
|
|
||||||
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
|
|
||||||
const seen: string[] = [];
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
}
|
}
|
||||||
expect(
|
|
||||||
seen.every((v) => v === '1000×'),
|
|
||||||
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// …and the server confirms it.
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
|
||||||
|
|
||||||
// Restore to min for later tests (afterEach restores scale anyway).
|
|
||||||
await page.keyboard.press('Home');
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
||||||
@@ -196,3 +202,16 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
|
|||||||
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
|
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
|
||||||
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// App Router file convention: app/icon.svg is auto-served and injected into the head.
|
||||||
|
const link = page.locator('head link[rel~="icon"]');
|
||||||
|
await expect(link).toHaveCount(1);
|
||||||
|
// Production builds append a content-hash query string to the auto-injected icon href.
|
||||||
|
expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/);
|
||||||
|
|
||||||
|
const res = await page.request.get('/icon.svg');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
expect(res.headers()['content-type']).toContain('image/svg+xml');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses
|
|
||||||
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight
|
|
||||||
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses →
|
|
||||||
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded
|
|
||||||
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
|
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
|
||||||
|
|
||||||
interface SimState {
|
|
||||||
clockMs: number;
|
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
|
||||||
simState: SimState;
|
|
||||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
|
||||||
const res = await page.request.get('/api/state');
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
return (await res.json()) as StateBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
|
||||||
// resume it or the clock stays frozen and the next wait hangs.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
const body = (await res.json()) as SimState;
|
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
|
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string }> {
|
|
||||||
const res = await page.request.get('/api/airports');
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
|
||||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
|
||||||
for (const f of fc.features) {
|
|
||||||
if (f.properties.icao === HUB) continue;
|
|
||||||
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
|
||||||
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
|
||||||
if (d < dn) nearest = f.properties;
|
|
||||||
}
|
|
||||||
return { icao: nearest.icao };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
|
|
||||||
// reposition plane-starter home via the API so every test starts from the canonical state even
|
|
||||||
// after an interrupted previous run.
|
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
|
||||||
let s = await getState(page);
|
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
}
|
|
||||||
if (plane.status === 'flying') {
|
|
||||||
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
|
|
||||||
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
|
|
||||||
// flight is needed.
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
|
||||||
s = await getState(page);
|
|
||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
}
|
|
||||||
if (plane.locationIcao !== HUB) {
|
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
|
|
||||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
|
||||||
test.afterEach(async ({ page }) => {
|
|
||||||
await flyHomeViaApi(page);
|
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
|
||||||
|
|
||||||
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => {
|
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
|
||||||
|
|
||||||
const nearest = await nearestToHub(page);
|
|
||||||
|
|
||||||
// Set 1000× via the API and verify it stuck.
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
|
|
||||||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
|
||||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
|
||||||
expect(flyRes.status()).toBe(200);
|
|
||||||
|
|
||||||
// Wait for the landing…
|
|
||||||
let landedBody: StateBody | undefined;
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const b = await getState(page);
|
|
||||||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
|
||||||
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
|
|
||||||
return !!landedBody;
|
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it…
|
|
||||||
expect(landedBody!.simState.paused).toBe(true);
|
|
||||||
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch.
|
|
||||||
expect(landedBody!.simState.timeScale).toBe(SCALE);
|
|
||||||
|
|
||||||
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full
|
|
||||||
// post-action snapshot, so no poll race with the client's next tick.
|
|
||||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
|
||||||
expect(fly2.status()).toBe(200);
|
|
||||||
const resumedBody = (await fly2.json()) as StateBody;
|
|
||||||
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause…
|
|
||||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect
|
|
||||||
|
|
||||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
|
||||||
});
|
|
||||||
+293
-51
@@ -1,19 +1,42 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
|
||||||
|
import { loadAirportsGeoJson } from '../lib/airports';
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
|
||||||
|
import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||||
// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and
|
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||||
|
// while flying, ISS-053), and
|
||||||
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
|
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
|
||||||
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
||||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale —
|
||||||
|
// a lagged tick can only finish the leg sooner, never skip an observed state.
|
||||||
|
const FAST = 10_000;
|
||||||
|
// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's
|
||||||
|
// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain.
|
||||||
|
const CHAIN = 4_000;
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||||
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
|
model: string;
|
||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
queue: string[];
|
queue: string[];
|
||||||
@@ -29,34 +52,33 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
return page.request.post('/api/sim', { data: body });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -68,6 +90,25 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string): Prom
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
|
||||||
|
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
|
||||||
|
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
|
||||||
|
async function setCash(page: Page, amount: number): Promise<void> {
|
||||||
|
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
@@ -89,7 +130,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
@@ -200,9 +241,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);
|
||||||
|
|
||||||
@@ -216,9 +256,9 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
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 section').toBeVisible({ timeout: 15_000 });
|
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// LeftPane's own /api/airports fetch populates the queue-add select — wait for it.
|
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
|
||||||
const addSelect = page.getByTestId('queue-add-dest');
|
const destSelect = page.getByTestId('relocate-dest');
|
||||||
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
|
||||||
// START leg 1 via the UI (fly-to button in the on-board block).
|
// START leg 1 via the UI (fly-to button in the on-board block).
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
@@ -230,32 +270,36 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
await setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
|
||||||
|
|
||||||
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected…
|
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||||
const addButton = page.getByTestId('queue-add');
|
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
await expect(addButton).toBeDisabled();
|
expect(dupQueue.status()).toBe(409);
|
||||||
await addSelect.selectOption(QUEUE);
|
|
||||||
await expect(addButton).toBeEnabled();
|
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
||||||
await addButton.click();
|
// posts to /queue and the leg starts when it lands.
|
||||||
|
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||||||
|
await destSelect.selectOption(QUEUE);
|
||||||
|
await page.getByTestId('relocate-go').click();
|
||||||
|
|
||||||
// …and the new leg appears as queue-leg-0 with its destination.
|
// …and the new leg appears as queue-leg-0 with its destination.
|
||||||
const legRow = page.getByTestId('queue-leg-0');
|
const legRow = page.getByTestId('queue-leg-plane-starter-0');
|
||||||
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
|
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
|
||||||
|
|
||||||
// CANCEL it via the UI and assert it's gone.
|
// CANCEL it via the UI and assert it's gone.
|
||||||
await page.getByTestId('queue-cancel-0').click();
|
await page.getByTestId('queue-cancel-plane-starter-0').click();
|
||||||
await expect(page.getByTestId('queue-leg-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||||
|
|
||||||
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||||
await setScale(page, SCALE);
|
// Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => {
|
await expect.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');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
@@ -268,9 +312,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].
|
||||||
@@ -285,8 +328,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
const flyBody = (await flyRes.json()) as StateBody;
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
// Freeze mid-flight (scale 1) so the ~10 s leg can't land under us while we assert.
|
||||||
await postSim(page, { paused: true });
|
await setScale(page, 1);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -330,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
// to arrival, then fly home.
|
// to arrival, then fly home.
|
||||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
expect(cancelRes.status()).toBe(200);
|
expect(cancelRes.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// Pure arrival waits — FAST is safe and ~10× quicker than SCALE here.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => {
|
await expect.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');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
@@ -346,13 +390,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);
|
||||||
@@ -364,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
|||||||
expect(flyRes.status()).toBe(200);
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||||||
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
// return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at
|
||||||
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
// CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, CHAIN);
|
||||||
await expect.poll(async () => {
|
await expect.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');
|
||||||
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||||||
@@ -382,3 +483,144 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
|||||||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
|
||||||
|
|
||||||
|
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
|
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
|
||||||
|
for (const icao of [DEST, QUEUE, HUB]) {
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p0.status).toBe('flying');
|
||||||
|
expect(p0.destIcao).toBe(DEST);
|
||||||
|
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
|
||||||
|
|
||||||
|
// Landing at DEST auto-chains EHAM (leg 2)…
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// …and landing at EHAM chains the return to HUB (leg 3).
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
|
|
||||||
|
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
|
||||||
|
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
|
||||||
|
|
||||||
|
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
|
||||||
|
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
const STAGED_CASH = 20_000;
|
||||||
|
await setCash(page, STAGED_CASH);
|
||||||
|
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q.status()).toBe(200);
|
||||||
|
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
|
||||||
|
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
|
||||||
|
|
||||||
|
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
|
||||||
|
await setFuel(page, 'plane-starter', 0);
|
||||||
|
|
||||||
|
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
|
||||||
|
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
|
||||||
|
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
|
||||||
|
|
||||||
|
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
|
||||||
|
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
|
||||||
|
expect(landedFuel).toBeCloseTo(0, 6);
|
||||||
|
|
||||||
|
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
|
||||||
|
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
|
||||||
|
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
|
||||||
|
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
|
||||||
|
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
|
||||||
|
|
||||||
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
|
||||||
|
const liters = Math.max(0, target - landedFuel);
|
||||||
|
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
|
||||||
|
// Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must
|
||||||
|
// stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the
|
||||||
|
// snapshot window below is sized for this rate.
|
||||||
|
await setScale(page, 2_000);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
const s = await getState(page);
|
||||||
|
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
|
||||||
|
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||||||
|
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
||||||
|
// > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind
|
||||||
|
// load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
||||||
|
expect(fuel).toBeGreaterThan(required2);
|
||||||
|
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
|
||||||
|
|
||||||
|
// Let leg 2 finish so the next test starts from a known airport.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one leg at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
|
||||||
|
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancel.status()).toBe(200);
|
||||||
|
|
||||||
|
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||||
|
await setScale(page, FAST);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p.destIcao).toBeNull(); // no chained leg started…
|
||||||
|
expect(p.queue).toEqual([]); // …and the queue is empty
|
||||||
|
});
|
||||||
|
|||||||
+145
-33
@@ -1,43 +1,73 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
|
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
|
||||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||||||
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
|
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||||
// the existing e2e/map.spec.ts click tests, not here.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — cleanup legs land in ~1 s
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
|
||||||
// away at the initial zoom, so a click on its projected center cannot hit another circle.
|
// shows both Africa + Europe with overlapping markers (LFRS's nearest neighbour LFRB Brest is only
|
||||||
|
// ~13–19 px away at z2), so framePopupAirport below frames it at z5 first — its nearest marker is then
|
||||||
|
// ~104 px away and a click on the projected center cannot hit another circle.
|
||||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||||
|
|
||||||
|
async function framePopupAirport(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((a) => (window as any).__flightMap.jumpTo({ center: [a.lon, a.lat], zoom: 5 }), POPUP_AIRPORT);
|
||||||
|
// Wait until the marker is actually rendered at the new camera before clicking it. The airport is
|
||||||
|
// passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope values don't exist.
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate((a) => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([a.lon, a.lat]);
|
||||||
|
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
|
||||||
|
.some((f) => f.properties?.icao === a.icao);
|
||||||
|
}, POPUP_AIRPORT),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.ok()).toBeTruthy();
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
expect(res.ok()).toBeTruthy();
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
|
||||||
// resume it or the clock stays frozen and the next wait hangs.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
|
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
|
||||||
@@ -63,8 +93,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
let s = await getState(page);
|
let s = await getState(page);
|
||||||
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 (~2 s real time worst case at FAST).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
}
|
}
|
||||||
if (plane.status === 'flying') {
|
if (plane.status === 'flying') {
|
||||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
||||||
@@ -74,9 +104,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at FAST so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
await setScale(page, FAST);
|
||||||
await setScale(page, SCALE);
|
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
@@ -84,22 +113,20 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
|
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
|
||||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
// fast-forward + reposition home here; then restore the fixed world rate for later specs.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
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 ~2 s at FAST) + 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);
|
||||||
|
|
||||||
@@ -127,11 +154,11 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
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 ~2 s at FAST) + 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('/');
|
||||||
|
|
||||||
@@ -156,6 +183,9 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
|
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
|
||||||
|
await framePopupAirport(page);
|
||||||
|
|
||||||
// Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is
|
// Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is
|
||||||
// passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist.
|
// passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist.
|
||||||
const pt = await page.evaluate(({ lon, lat }) => {
|
const pt = await page.evaluate(({ lon, lat }) => {
|
||||||
@@ -189,3 +219,85 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
||||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg, fast-forwarded in cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
|
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||||||
|
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||||||
|
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||||||
|
// deterministic regardless of how fast the UI moves.
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||||||
|
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m || !m.getSource('planes')) return false;
|
||||||
|
try {
|
||||||
|
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
|
||||||
|
} catch {
|
||||||
|
return false; // source not ready yet — keep polling
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
|
||||||
|
await framePopupAirport(page);
|
||||||
|
|
||||||
|
const pt = await page.evaluate(({ lon, lat }) => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([lon, lat]);
|
||||||
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
|
}, POPUP_AIRPORT);
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText(POPUP_AIRPORT.icao);
|
||||||
|
|
||||||
|
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||||||
|
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||||||
|
const planeSel = page.getByTestId('airport-popup-plane');
|
||||||
|
await expect(planeSel).toBeVisible();
|
||||||
|
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
|
||||||
|
const goBtn = page.getByTestId('airport-popup-relocate');
|
||||||
|
await expect(goBtn).toBeDisabled(); // placeholder selected
|
||||||
|
|
||||||
|
await planeSel.selectOption('plane-starter');
|
||||||
|
await expect(goBtn).toBeEnabled();
|
||||||
|
await goBtn.click();
|
||||||
|
|
||||||
|
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||||||
|
// the flight queue while the plane keeps flying its original destination.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
+173
-156
@@ -1,16 +1,30 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/movement';
|
||||||
|
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
|
||||||
|
import { STARTING_REPUTATION } from '../lib/game/seed';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||||
// abandon buttons (penalty preview from the same
|
// abandon buttons (penalty preview from the same
|
||||||
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
||||||
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
|
// the catalog + economy constants exposed in /api/state, and dispatch auto-refuels a low tank to
|
||||||
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
|
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
||||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
@@ -19,12 +33,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageState {
|
interface PackageState {
|
||||||
id: string;
|
id: string;
|
||||||
|
originIcao: string; // v0.65.0 — pool visibility assertions need the origin
|
||||||
|
destIcao: string;
|
||||||
originName: string;
|
originName: string;
|
||||||
destName: string;
|
destName: string;
|
||||||
reward: number;
|
reward: number;
|
||||||
@@ -32,6 +49,7 @@ interface PackageState {
|
|||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
|
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogEntry {
|
interface CatalogEntry {
|
||||||
@@ -47,7 +65,7 @@ interface CatalogEntry {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
@@ -59,29 +77,28 @@ interface StateBody {
|
|||||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
@@ -103,7 +120,8 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
|
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
// Returns both endpoints' coordinates so exact-km fuel assertions can mirror lib/geo.ts.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
};
|
};
|
||||||
@@ -115,32 +133,7 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
nearest = f.properties;
|
nearest = f.properties;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nearest;
|
return { ...nearest, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
}
|
|
||||||
|
|
||||||
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
|
|
||||||
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
|
|
||||||
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
|
|
||||||
// this test wants a long-but-flyable leg instead.
|
|
||||||
async function farthestFlyableToHub(
|
|
||||||
page: Page,
|
|
||||||
entry: CatalogEntry,
|
|
||||||
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
|
||||||
};
|
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
|
||||||
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
|
||||||
for (const f of fc.features) {
|
|
||||||
if (f.properties.icao === HUB) continue;
|
|
||||||
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
|
||||||
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
|
||||||
if (!farthest || d > farthest.d) {
|
|
||||||
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
|
|
||||||
return { ...farthest, hubLat: hub.lat, hubLon: hub.lon };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||||
@@ -151,16 +144,11 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
|
|||||||
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
|
||||||
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
|
||||||
const rho = FUEL_DENSITY_KG_PER_L;
|
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
|
||||||
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
|
||||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
|
||||||
const created = await page.request.post('/api/test/create-package', {
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
|
||||||
});
|
});
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
@@ -168,7 +156,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×).
|
||||||
@@ -183,11 +175,11 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
|
||||||
// fast-forwards instead of taking real-time hours.
|
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -195,6 +187,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||||
|
const s1 = await getState(page);
|
||||||
|
for (const pkg of s1.packages) {
|
||||||
|
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||||
@@ -210,13 +210,20 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(rows.first()).toContainText(/→/);
|
await expect(rows.first()).toContainText(/→/);
|
||||||
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||||
|
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
|
||||||
|
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
|
||||||
|
|
||||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
const pkg = await createPackage(page, HUB, nearest.icao);
|
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
|
||||||
|
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||||||
|
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
||||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||||
|
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
|
||||||
|
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
await expect(loadBtn).toBeEnabled();
|
await expect(loadBtn).toBeEnabled();
|
||||||
|
|
||||||
@@ -231,15 +238,36 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
test('a priority package shows its special badge on the open-package row', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||||
|
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
|
||||||
|
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
|
||||||
|
|
||||||
const pkg = await createPackage(page, 'LEMD', HUB);
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
|
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
|
||||||
|
const badge = page.getByTestId(`package-special-${pkg.id}`);
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveText('Priority');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open jobs only appear where a plane can take them — foreign-origin jobs are hidden', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF}
|
||||||
|
|
||||||
|
const pkg = await createPackage(page, 'LEMD', HUB);
|
||||||
|
expect(pkg.originIcao).toBe('LEMD');
|
||||||
|
// v0.65.0: open jobs are only visible from airports where a plane can take them (the pool). LEMD is
|
||||||
|
// not in the pool while the starter sits at EDDF, so the job exists server-side but never reaches
|
||||||
|
// /api/state — no row and no disabled Load button to speak of.
|
||||||
|
expect((await getState(page)).packages.some((p) => p.id === pkg.id), 'a non-pool-origin open job must not appear in /api/state').toBe(false);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||||
@@ -255,21 +283,27 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
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({ timeout: 15_000 });
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
// The abandon button previews the penalty at its snapshot's clock (same formula as the server).
|
||||||
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||||
// making the preview deterministic instead of racing the 1 s poll loop.
|
// other planes), so the world clock is FROZEN (#135) — verify it actually is, then the preview must
|
||||||
await postSim(page, { paused: true });
|
// match the server exactly: a frozen clock means zero drift between any two snapshots.
|
||||||
|
const c0 = (await getState(page)).simState.clockMs;
|
||||||
|
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||||||
const state = await getState(page);
|
const state = await getState(page);
|
||||||
|
expect(state.simState.clockMs - c0, 'the grounded clock must stay frozen (#135)').toBe(0);
|
||||||
|
|
||||||
|
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
|
||||||
|
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
|
||||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||||
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
expect(Number(penaltyText), 'frozen clock → the preview matches the server exactly').toBe(expectedPenalty);
|
||||||
|
|
||||||
// Fly to the destination through the UI — the button is the destination's name.
|
// Fly to the destination through the UI — the button is the destination's name.
|
||||||
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();
|
||||||
|
|
||||||
// The on-board block now shows the plane in flight with its current leg…
|
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
|
||||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||||
await expect(leg).toBeVisible();
|
await expect(leg).toBeVisible();
|
||||||
await expect(leg).toContainText('→');
|
await expect(leg).toContainText('→');
|
||||||
@@ -279,8 +313,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival (poll the authoritative state).
|
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
|
||||||
await postSim(page, { paused: false });
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -295,8 +328,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
// Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
|
||||||
// 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 homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(homeRes.status()).toBe(200);
|
expect(homeRes.status()).toBe(200);
|
||||||
@@ -308,7 +340,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
|
test('market buy/sell render with previews from the catalog in /api/state', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||||
|
|
||||||
@@ -321,11 +353,20 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
|
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
|
||||||
await page.getByTestId('tab-planes-market').click();
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
// Buy: the model picker lists the catalog entry and the price preview is its EFFECTIVE price —
|
||||||
|
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list price.
|
||||||
const buyModel = page.getByTestId('market-buy-model');
|
const buyModel = page.getByTestId('market-buy-model');
|
||||||
await expect(buyModel).toBeVisible({ timeout: 15_000 });
|
await expect(buyModel).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
|
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
|
||||||
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
|
const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
|
||||||
|
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
|
||||||
|
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
|
||||||
|
if (body.account.reputation > STARTING_REPUTATION) {
|
||||||
|
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
|
||||||
|
} else {
|
||||||
|
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
|
||||||
|
}
|
||||||
await expect(page.getByTestId('market-buy')).toBeVisible();
|
await expect(page.getByTestId('market-buy')).toBeVisible();
|
||||||
|
|
||||||
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
||||||
@@ -335,104 +376,80 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
||||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
|
|
||||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Refuel through the pane: the flight genuinely burned fuel…
|
|
||||||
if (starter.fuel < starter.maxFuel) {
|
|
||||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
|
||||||
const cashBefore = body.account.cash;
|
|
||||||
|
|
||||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
|
||||||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
|
||||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
|
||||||
await page.getByTestId('market-refuel').click();
|
|
||||||
|
|
||||||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const b = await getState(page);
|
|
||||||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
|
||||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
|
||||||
.toBe(true);
|
|
||||||
const after = await getState(page);
|
|
||||||
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
|
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead. This test pins the new
|
||||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
// behavior end-to-end: drop a grounded starter to low fuel via the hook, fly a leg, and assert the
|
||||||
|
// cash delta equals the EXACT rep-discounted top-up cost (computed from the snapshot + real lib
|
||||||
|
// helpers) while the post-flight landed fuel matches the weighted burn from the topped start value.
|
||||||
|
test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement and deducts exactly', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one short flight each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||||||
|
|
||||||
// Deterministic baseline: a full tank.
|
// Drop the grounded starter to a low, deterministic fuel level via the test hook — well below the
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
// 150%-of-requirement target even for the shortest dataset leg (EDFH, ~93 km), so a top-up is guaranteed.
|
||||||
|
const LOW_FUEL = 50;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: LOW_FUEL } })).status()).toBe(200);
|
||||||
|
|
||||||
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
|
// Nearest airport to the hub → short leg, fast at scale 1000.
|
||||||
// exceeds what's left on arrival.
|
const nearest = await nearestToHub(page);
|
||||||
const farthest = await farthestFlyableToHub(page, entry);
|
const pre = await getState(page);
|
||||||
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
|
const starter = pre.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
|
||||||
|
|
||||||
// Fly there (delivers pkg1 on arrival) — the tank is now partial.
|
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200);
|
// wind-adjusted requirement, 150% margin capped at tank capacity.
|
||||||
|
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
const required = requiredFuelFor(
|
||||||
|
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||||||
|
entry.emptyWeightKg,
|
||||||
|
entry.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
const target = Math.min(entry.maxFuel, Math.max(LOW_FUEL, required * 1.5)); // ISS-082: 150% margin, capped at tank capacity
|
||||||
|
const liters = Math.max(0, target - LOW_FUEL);
|
||||||
|
expect(liters).toBeGreaterThan(0); // the low tank genuinely needs a top-up
|
||||||
|
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||||||
|
const cashBefore = pre.account.cash;
|
||||||
|
|
||||||
|
// Dispatch via the API — the response snapshot is authoritative at the action's clock.
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(200); // auto-refuel made the low tank flyable…
|
||||||
|
const post = (await flyRes.json()) as StateBody;
|
||||||
|
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // …departing with exactly the topped level
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||||
|
|
||||||
|
// Fast-forward to arrival; the landed fuel is the weighted burn from the TOPPED start value.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const b = await getState(page);
|
const b = await getState(page);
|
||||||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === farthest.icao;
|
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||||||
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` })
|
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for.
|
const landed = await getState(page);
|
||||||
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
|
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
|
||||||
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
|
||||||
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
|
// Restore: fly home (auto-refuel covers the return leg) and top the tank back up for later specs.
|
||||||
const pre = await getState(page);
|
|
||||||
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
|
|
||||||
const cashBefore = pre.account.cash;
|
|
||||||
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
|
|
||||||
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
|
|
||||||
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
|
|
||||||
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
|
|
||||||
await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 });
|
|
||||||
// Capture the endpoint's OWN response — the full post-action snapshot at the action's clock.
|
|
||||||
const [res] = await Promise.all([
|
|
||||||
page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }),
|
|
||||||
refuelBtn.click(),
|
|
||||||
]);
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
const post = (await res.json()) as StateBody;
|
|
||||||
|
|
||||||
// Cash dropped by exactly L × price and fuel rose by exactly L.
|
|
||||||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
|
||||||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
|
||||||
|
|
||||||
// Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
|
|
||||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
|
||||||
|
|
||||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
|
||||||
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
|
|
||||||
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
timeout: 240_000,
|
timeout: 120_000,
|
||||||
message: 'waiting for plane-starter to land back at the hub',
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
// Top the tank back up so later specs start with a full tank.
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
||||||
@@ -442,11 +459,11 @@ test('Planes market tab lists all plane types with tech specs', async ({ page })
|
|||||||
|
|
||||||
await page.getByTestId('tab-planes-market').click();
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
|
// ATR card: name + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0).
|
||||||
const atr = body.catalog.find((e) => e.model === 'atr72')!;
|
const atr = body.catalog.find((e) => e.model === 'atr72')!;
|
||||||
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
|
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
|
||||||
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
|
const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
|
||||||
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
|
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
|
||||||
|
|
||||||
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
|
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
|
||||||
|
|||||||
+166
-102
@@ -1,157 +1,221 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it
|
||||||
|
// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one
|
||||||
|
// pool, at EDDF.
|
||||||
|
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
|
||||||
|
|
||||||
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
import { authenticate } from './auth';
|
||||||
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
|
|
||||||
// hook enabled in docker-compose.yml), and market top-up.
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
|
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
||||||
|
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
||||||
|
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
|
||||||
|
// market top-up.
|
||||||
|
|
||||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
||||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
|
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
|
||||||
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
const STARTER = 'plane-starter';
|
||||||
const MARKET_TARGET = 10;
|
|
||||||
|
|
||||||
interface SimState {
|
|
||||||
clockMs: number;
|
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<{ id: string; status: string }>;
|
||||||
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
|
||||||
return page.request.post('/api/sim', { data: body });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
test.afterEach(async ({ page }) => {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||||
|
|
||||||
|
const c0 = s.simState.clockMs;
|
||||||
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
|
||||||
|
const after = await getState(page);
|
||||||
|
|
||||||
|
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
|
||||||
|
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
|
||||||
|
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() !== 200) return 'down';
|
||||||
|
const body = (await res.json()) as StateBody;
|
||||||
|
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
|
||||||
|
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
|
||||||
|
.toBe('idle');
|
||||||
|
|
||||||
|
const before = await getState(page);
|
||||||
|
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||||
|
const clockBefore = before.simState.clockMs;
|
||||||
|
|
||||||
|
// Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
|
||||||
|
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
|
||||||
|
// only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
|
||||||
|
// reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
|
||||||
|
// its removal, so the market can never empty; cap the passes to bound runtime either way (a
|
||||||
|
// zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
|
||||||
|
// board comes back on the very next tick.
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
const s = await getState(page);
|
||||||
|
const open = s.packages.filter((p) => p.status === 'open');
|
||||||
|
if (open.length === 0) break; // drained — the pre-fix soft-lock state
|
||||||
|
for (const p of open) {
|
||||||
|
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
|
||||||
|
// pool right away — within ~15 s of real time, with headroom for dind slowness…
|
||||||
|
let topped: StateBody | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const body = await getState(page);
|
||||||
|
if (body.packages.some((p) => p.status === 'open')) topped = body;
|
||||||
|
return !!topped;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const after = topped!;
|
||||||
|
expect(after.packages.filter((p) => p.status === 'open').length).toBeGreaterThan(0); // starvation fixed…
|
||||||
|
expect(after.planes.every((p) => p.status === 'available'), 'no plane may have taken off during the drain').toBe(true); // …with no plane flying…
|
||||||
|
expect(after.simState.clockMs, 'the grounded clock must stay exactly frozen while the market refills').toBe(clockBefore); // …and the world still frozen
|
||||||
});
|
});
|
||||||
|
|
||||||
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
||||||
const post = await postSim(page, { timeScale: SCALE });
|
await activateWorld(page);
|
||||||
expect(post.status()).toBe(200);
|
try {
|
||||||
const s1 = (await post.json()) as SimState;
|
await setScale(page, SCALE);
|
||||||
expect(s1.timeScale).toBe(SCALE);
|
|
||||||
expect(s1.paused).toBe(false);
|
|
||||||
|
|
||||||
let prev = s1.clockMs;
|
let prev = (await getState(page)).simState.clockMs;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
||||||
const body = await getState(page);
|
const now = (await getState(page)).simState.clockMs;
|
||||||
const now = body.simState.clockMs;
|
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
|
||||||
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
|
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
||||||
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
||||||
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
prev = now;
|
||||||
prev = now;
|
}
|
||||||
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('paused freezes the clock exactly across polls', async ({ page }) => {
|
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
|
||||||
const post = await postSim(page, { timeScale: SCALE, paused: true });
|
|
||||||
expect(post.status()).toBe(200);
|
|
||||||
const s1 = (await post.json()) as SimState;
|
|
||||||
expect(s1.paused).toBe(true);
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
await sleep(500);
|
|
||||||
const body = await getState(page);
|
|
||||||
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
|
||||||
test.setTimeout(60_000);
|
|
||||||
|
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
const victim = before.packages.find((p) => p.status === 'open');
|
const victim = before.packages.find((p) => p.status === 'open');
|
||||||
expect(victim).toBeDefined(); // steady state always has open packages
|
expect(victim).toBeDefined(); // steady state always has open packages
|
||||||
|
|
||||||
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
|
||||||
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
|
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
|
||||||
|
// scale or clock activation needed.
|
||||||
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||||
expect(hook.status()).toBe(200);
|
expect(hook.status()).toBe(200);
|
||||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
const moved = (await hook.json()) as { id: string; status: string };
|
||||||
expect(moved.id).toBe(victim!.id);
|
expect(moved.id).toBe(victim!.id);
|
||||||
|
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
|
||||||
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
// Expired jobs leave the market…
|
||||||
|
let after: StateBody | undefined;
|
||||||
let expiredBody: StateBody | undefined;
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||||||
return !!expiredBody;
|
return !!after;
|
||||||
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
// …and the spawner refills the pool back up — never below its target.
|
||||||
const after = expiredBody!;
|
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
|
||||||
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
|
||||||
// Expired packages leave the available set…
|
|
||||||
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
|
||||||
// …and spawn tops the market back up to the target — never below it.
|
|
||||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
|
||||||
|
|
||||||
const created = await page.request.post('/api/test/expire-package');
|
const created = await page.request.post('/api/test/expire-package');
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
const c = (await created.json()) as { id: string; status: string };
|
const c = (await created.json()) as { id: string; status: string };
|
||||||
expect(c.status).toBe('open');
|
// The hook ages the job before its final tick, so it never even appears open in /api/state.
|
||||||
|
expect(c.status).toBe('expired');
|
||||||
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
let after: StateBody | undefined;
|
||||||
|
|
||||||
let expiredBody: StateBody | undefined;
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||||||
return !!expiredBody;
|
return !!after;
|
||||||
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
|
||||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /api/sim validates and clamps its input', async ({ page }) => {
|
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
|
||||||
const clampHigh = await postSim(page, { timeScale: 2000 });
|
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
|
||||||
expect(clampHigh.status()).toBe(200);
|
expect(ok.status()).toBe(200);
|
||||||
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0)
|
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
|
||||||
|
|
||||||
const inRange = await postSim(page, { timeScale: 500 });
|
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
|
||||||
expect(inRange.status()).toBe(200);
|
const res = await page.request.post('/api/test/set-timescale', { data: body });
|
||||||
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
|
|
||||||
|
|
||||||
const clampLow = await postSim(page, { timeScale: 0.1 });
|
|
||||||
expect(clampLow.status()).toBe(200);
|
|
||||||
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
|
|
||||||
|
|
||||||
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
|
|
||||||
const res = await postSim(page, body);
|
|
||||||
expect(res.status()).toBe(400);
|
expect(res.status()).toBe(400);
|
||||||
}
|
}
|
||||||
const notJson = await page.request.post('/api/sim', { data: 'nope{' });
|
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
|
||||||
expect(notJson.status()).toBe(400);
|
expect(notJson.status()).toBe(400);
|
||||||
|
|
||||||
// Empty body is a valid no-op (just ticks + returns current state).
|
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
|
||||||
const empty = await postSim(page, {});
|
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
|
||||||
expect(empty.status()).toBe(200);
|
expect(empty.status()).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
+69
-14
@@ -2,6 +2,11 @@ import { execSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
|
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
|
||||||
@@ -10,7 +15,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
@@ -24,9 +29,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
expect(typeof body.account.cash).toBe('number');
|
expect(typeof body.account.cash).toBe('number');
|
||||||
expect(typeof body.account.reputation).toBe('number');
|
expect(typeof body.account.reputation).toBe('number');
|
||||||
|
|
||||||
expect(typeof body.simState.clockMs).toBe('number');
|
expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
|
||||||
expect(typeof body.simState.timeScale).toBe('number');
|
|
||||||
expect(body.simState.paused).toBe(false);
|
|
||||||
|
|
||||||
expect(Array.isArray(body.planes)).toBe(true);
|
expect(Array.isArray(body.planes)).toBe(true);
|
||||||
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -44,6 +47,17 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
// Self-heal to a fully grounded fleet first — a grounded fleet freezes the world clock (#135),
|
||||||
|
// so no tick advances it across the restart and the post-restart clock is exactly unchanged.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() !== 200) return 'down';
|
||||||
|
const body = (await res.json()) as StateBody;
|
||||||
|
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
|
||||||
|
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
|
||||||
|
.toBe('idle');
|
||||||
|
|
||||||
const resBefore = await page.request.get('/api/state');
|
const resBefore = await page.request.get('/api/state');
|
||||||
expect(resBefore.status()).toBe(200);
|
expect(resBefore.status()).toBe(200);
|
||||||
const before = (await resBefore.json()) as StateBody;
|
const before = (await resBefore.json()) as StateBody;
|
||||||
@@ -67,18 +81,59 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
expect(after).toBeDefined();
|
expect(after).toBeDefined();
|
||||||
const a = after!;
|
const a = after!;
|
||||||
|
|
||||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
|
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
|
||||||
// persisted across sessions"): account and entity ids are byte-identical.
|
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, the world
|
||||||
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one
|
// clock is FROZEN (#135) — no tick advances it while the container is down or after restart, so
|
||||||
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the
|
// the post-restart clock is EXACTLY unchanged; a re-seed would reset clockMs backwards instead.
|
||||||
// post-restart clock may be ahead — but never by more than that bound.
|
expect(a.simState.clockMs - before.simState.clockMs).toBe(0);
|
||||||
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
|
|
||||||
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
|
|
||||||
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
|
|
||||||
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
|
|
||||||
);
|
|
||||||
expect(a.account.cash).toBe(before.account.cash);
|
expect(a.account.cash).toBe(before.account.cash);
|
||||||
expect(a.account.reputation).toBe(before.account.reputation);
|
expect(a.account.reputation).toBe(before.account.reputation);
|
||||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||||
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
|
||||||
|
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() !== 200) return 'down';
|
||||||
|
const body = (await res.json()) as StateBody;
|
||||||
|
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
|
||||||
|
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
|
||||||
|
.toBe('available');
|
||||||
|
|
||||||
|
// Stage a two-leg queue on the starter…
|
||||||
|
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
|
||||||
|
expect(q1.status()).toBe(200);
|
||||||
|
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
|
||||||
|
expect(q2.status()).toBe(200);
|
||||||
|
|
||||||
|
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Wait for the app to come back, capturing the post-restart snapshot.
|
||||||
|
let after: StateBody | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) after = (await res.json()) as StateBody;
|
||||||
|
return !!after;
|
||||||
|
} catch {
|
||||||
|
return false; // connection refused / 502 while the container starts
|
||||||
|
}
|
||||||
|
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
|
||||||
|
|
||||||
|
// Cleanup: cancel both legs so the starter is left with an empty queue.
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c.status()).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,11 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
|
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
|
||||||
// right pane, in addition to the inline pane error) and game events appear as info toasts via
|
// right pane, in addition to the inline pane error) and game events appear as info toasts via
|
||||||
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
|
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
|
||||||
@@ -18,9 +23,14 @@ interface StateBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
expect(res.status()).toBe(200);
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
return (await res.json()) as StateBody;
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
|
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||||
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
|
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||||
|
// spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
|
||||||
|
// per-plane queues (scoped testids) and sell.
|
||||||
|
//
|
||||||
|
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||||
|
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||||
|
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||||
|
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
|
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
queue: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageState {
|
||||||
|
id: string;
|
||||||
|
originIcao: string;
|
||||||
|
destIcao: string;
|
||||||
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
|
planeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
price: number;
|
||||||
|
maxFuel: number;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
speedKmh: number;
|
||||||
|
maxPayloadKg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
|
planes: PlaneState[];
|
||||||
|
packages: PackageState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
fuelPricePerL: number;
|
||||||
|
sellFraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
|
||||||
|
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||||
|
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||||
|
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
|
||||||
|
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
|
||||||
|
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
|
||||||
|
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
|
||||||
|
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
|
||||||
|
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
return fc.features
|
||||||
|
.filter((f) => f.properties.icao !== HUB)
|
||||||
|
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||||
|
.sort((x, y) => x.d - y.d)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
|
||||||
|
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
|
||||||
|
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
|
||||||
|
// return leg is flown after a top-up, which also 409s if it doesn't fit.
|
||||||
|
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||||||
|
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
||||||
|
const wDest = weatherFor(f.properties.icao);
|
||||||
|
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
|
||||||
|
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
||||||
|
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
|
||||||
|
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
|
||||||
|
if (!farthest || d > farthest.d) {
|
||||||
|
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
|
||||||
|
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
|
||||||
|
async function createPackage(
|
||||||
|
page: Page,
|
||||||
|
fromIcao: string,
|
||||||
|
toIcao: string,
|
||||||
|
deadlineOffsetMs?: number,
|
||||||
|
weightKg?: number,
|
||||||
|
): Promise<PackageState> {
|
||||||
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
|
||||||
|
});
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
return (await created.json()) as PackageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
|
||||||
|
// post-grant snapshot (the route's response).
|
||||||
|
async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const p = s.planes.find((x) => x.id === planeId)!;
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
||||||
|
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||||||
|
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||||||
|
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const right = page.getByTestId('right-pane');
|
||||||
|
await expect(right).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
|
const buyModel = page.getByTestId('market-buy-model');
|
||||||
|
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
|
||||||
|
await buyModel.selectOption(BUY_MODEL);
|
||||||
|
await page.getByTestId('market-buy').click();
|
||||||
|
|
||||||
|
let secondId: string | null = null;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
|
||||||
|
return secondId !== null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
|
||||||
|
.toBe(true);
|
||||||
|
return secondId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||||
|
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||||
|
if (q.length === 0) return;
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
|
||||||
|
// carrying cargo, and clean baselines keep exact-cash assertions honest.
|
||||||
|
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
for (const pkg of s.packages) {
|
||||||
|
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
|
||||||
|
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
|
||||||
|
// specs, generalized to an arbitrary plane id.
|
||||||
|
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||||||
|
await clearQueueViaApi(page, planeId);
|
||||||
|
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||||||
|
if (!p) return; // already sold — nothing to heal
|
||||||
|
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
if (p.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return s.planes.find((x) => x.id === planeId)?.status ?? '';
|
||||||
|
}, {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for an in-flight ${planeId} to land`,
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
|
}
|
||||||
|
if (p.locationIcao !== HUB) {
|
||||||
|
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
|
||||||
|
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for ${planeId} to land back at the hub`,
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
|
||||||
|
// in flight) — leaves the fleet at exactly one plane.
|
||||||
|
async function sellNonStarters(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
|
||||||
|
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
|
||||||
|
await abandonLeftoverCargo(page, p.id);
|
||||||
|
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||||
|
// no cargo on board — even after an interrupted previous run.
|
||||||
|
async function resetFleet(page: Page): Promise<void> {
|
||||||
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
await sellNonStarters(page);
|
||||||
|
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||||
|
await abandonLeftoverCargo(page, STARTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await resetFleet(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
|
||||||
|
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
|
||||||
|
|
||||||
|
const [nearest] = await twoNearestToHub(page);
|
||||||
|
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
|
||||||
|
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
|
||||||
|
// The multi-plane picker lists BOTH planes…
|
||||||
|
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
|
||||||
|
await expect(picker).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
|
||||||
|
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
|
||||||
|
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
|
||||||
|
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
|
||||||
|
|
||||||
|
// One-click Load binds the package to the preselected (first eligible) plane.
|
||||||
|
await loadBtn1.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
|
||||||
|
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
|
||||||
|
.toBe(firstEligible.id);
|
||||||
|
|
||||||
|
// Second package — pick the OTHER plane explicitly via the dropdown.
|
||||||
|
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
|
||||||
|
await expect(picker2).toBeVisible({ timeout: 15_000 });
|
||||||
|
await picker2.selectOption(secondId);
|
||||||
|
await page.getByTestId(`load-${pkg2.id}`).click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
|
||||||
|
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
||||||
|
.toBe(secondId);
|
||||||
|
|
||||||
|
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
|
||||||
|
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000×
|
||||||
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
|
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||||
|
await setFuelFull(page, STARTER);
|
||||||
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
|
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||||
|
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||||
|
// the same tick (a shared-tick arrival would race the first-landing assert).
|
||||||
|
const [nearest] = await twoNearestToHub(page);
|
||||||
|
const farthest = await farthestFlyableToHub(page);
|
||||||
|
|
||||||
|
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
|
||||||
|
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
|
||||||
|
|
||||||
|
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyA.status()).toBe(200);
|
||||||
|
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
|
||||||
|
expect(flyB.status()).toBe(200);
|
||||||
|
|
||||||
|
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
|
||||||
|
const s1 = (await flyB.json()) as StateBody;
|
||||||
|
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
|
||||||
|
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
|
||||||
|
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||||||
|
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||||||
|
|
||||||
|
// Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a
|
||||||
|
// lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate.
|
||||||
|
await setScale(page, FAST);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the first plane to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for both planes to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const landed = await getState(page);
|
||||||
|
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||||||
|
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||||
|
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
|
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||||
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
|
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs
|
||||||
|
// dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the
|
||||||
|
// range-edge return leg at SCALE (~24 s).
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for both planes to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||||
|
// its absence is pinned in e2e/panes.spec.ts instead.
|
||||||
|
|
||||||
|
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||||
|
|
||||||
|
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
|
||||||
|
|
||||||
|
// The Relocate section lists BOTH grounded planes…
|
||||||
|
const relocateSelect = page.getByTestId('relocate-plane');
|
||||||
|
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||||
|
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
|
||||||
|
const destSelect = page.getByTestId('relocate-dest');
|
||||||
|
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Relocate ONLY the second plane…
|
||||||
|
await relocateSelect.selectOption(secondId);
|
||||||
|
await destSelect.selectOption(a.icao);
|
||||||
|
await page.getByTestId('relocate-go').click();
|
||||||
|
|
||||||
|
// …it is flying to the new airport while the starter stays put at EDDF.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
|
||||||
|
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// Let it land — locationIcao flips to the destination on arrival.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for the relocated plane to land at ${a.icao}`,
|
||||||
|
})
|
||||||
|
.toBe(a.icao);
|
||||||
|
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
const [a, b] = await twoNearestToHub(page);
|
||||||
|
|
||||||
|
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||||||
|
await setFuelFull(page, STARTER);
|
||||||
|
|
||||||
|
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
|
||||||
|
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
|
||||||
|
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
|
||||||
|
|
||||||
|
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
|
||||||
|
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||||
|
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
|
||||||
|
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
|
||||||
|
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||||||
|
|
||||||
|
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
|
||||||
|
// response snapshot must already show only [b] left (previously the leg stayed queued and the
|
||||||
|
// landing chained a zero-distance self-hop a→a before reaching b).
|
||||||
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||||
|
|
||||||
|
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
|
||||||
|
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
|
||||||
|
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
|
||||||
|
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||||
|
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||||
|
|
||||||
|
// …fast-forward the chained leg to its landing.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === b.icao;
|
||||||
|
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// The second plane never moved — still grounded at EDDF with an empty queue.
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||||
|
|
||||||
|
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||||||
|
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
|
||||||
|
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
|
||||||
|
|
||||||
|
// The Sell row lists BOTH grounded planes…
|
||||||
|
const sellSelect = page.getByTestId('market-sell-plane');
|
||||||
|
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||||
|
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
|
||||||
|
await sellSelect.selectOption(secondId);
|
||||||
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
|
await page.getByTestId('market-sell').click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// The last remaining plane can never be sold — even via the API.
|
||||||
|
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
|
||||||
|
expect(res.status()).toBe(409);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { startBackgroundTick } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// ISS-117 (v0.52.0): the world clock advances on a ~1 s background tick even with nobody polling —
|
||||||
|
// started once at server boot (Next ≥15 instrumentation hook; runs under `next dev` and `next start`).
|
||||||
|
export function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
startBackgroundTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-13
@@ -1,5 +1,8 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports';
|
import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports';
|
||||||
|
import { haversineKm } from './geo';
|
||||||
|
|
||||||
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 +56,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 +114,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');
|
||||||
@@ -123,3 +135,77 @@ describe('/api/airports handler', () => {
|
|||||||
expect(await a.json()).toEqual(await b.json());
|
expect(await a.json()).toEqual(await b.json());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isRemote (v0.61.0 — ISS-097 special remote airports)', () => {
|
||||||
|
it('synthetic: an isolated point is remote; a neighbour inside the radius is not', () => {
|
||||||
|
// Equator pair: Δlon 8.5° ≈ 945 km (inside REMOTE_RADIUS_KM) vs Δlon 9.5° ≈ 1052 km (outside).
|
||||||
|
const near = [
|
||||||
|
{ icao: 'AAA', lat: 0, lon: 0 },
|
||||||
|
{ icao: 'BBB', lat: 0, lon: 8.5 },
|
||||||
|
];
|
||||||
|
expect(isRemote(near, 'AAA')).toBe(false);
|
||||||
|
expect(isRemote(near, 'BBB')).toBe(false);
|
||||||
|
|
||||||
|
const far = [
|
||||||
|
{ icao: 'AAA', lat: 0, lon: 0 },
|
||||||
|
{ icao: 'CCC', lat: 0, lon: 9.5 },
|
||||||
|
];
|
||||||
|
expect(isRemote(far, 'AAA')).toBe(true);
|
||||||
|
expect(isRemote(far, 'CCC')).toBe(true);
|
||||||
|
|
||||||
|
// A lone airport has no other member to be near — remote by definition.
|
||||||
|
expect(isRemote([{ icao: 'SOLO', lat: -77.84, lon: 166.91 }], 'SOLO')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('real coords: Frankfurt↔Amsterdam (~370 km) is not remote; McMurdo vs Christchurch (~3800 km) is', () => {
|
||||||
|
const eu = [
|
||||||
|
{ icao: 'EDDF', lat: 50.0376, lon: 8.5622 }, // Frankfurt Main
|
||||||
|
{ icao: 'EHAM', lat: 52.3093, lon: 13.3698 }, // Amsterdam Schiphol
|
||||||
|
];
|
||||||
|
expect(isRemote(eu, 'EDDF')).toBe(false);
|
||||||
|
|
||||||
|
const pacific = [
|
||||||
|
{ icao: 'MCDD', lat: -77.8425, lon: 166.9133 }, // McMurdo Station (Antarctica)
|
||||||
|
{ icao: 'NZCH', lat: -43.4893, lon: 172.5308 }, // Christchurch
|
||||||
|
];
|
||||||
|
expect(isRemote(pacific, 'MCDD')).toBe(true);
|
||||||
|
expect(REMOTE_RADIUS_KM).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown ICAO is not remote', () => {
|
||||||
|
expect(isRemote([{ icao: 'AAA', lat: 0, lon: 0 }], 'ZZZ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('committed dataset curation rule: every non-large row has no large airport within REMOTE_RADIUS_KM', async () => {
|
||||||
|
const text = await fs.readFile(path.join(process.cwd(), 'data', 'airports_world.csv'), 'utf8');
|
||||||
|
const rows = filterWorldAirports(parseCsv(text));
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const large = rows.filter((r) => r.type === 'large_airport').map((r) => ({ icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg }));
|
||||||
|
const nonLarge = rows.filter((r) => r.type !== 'large_airport');
|
||||||
|
// The dataset must actually carry the "special" airports — not just large ones.
|
||||||
|
expect(nonLarge.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const r of nonLarge) {
|
||||||
|
const self = { icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg };
|
||||||
|
// Remote against the kept set: no LARGE airport within REMOTE_RADIUS_KM (the curation rule).
|
||||||
|
expect(isRemote([...large, self], self.icao), `${self.icao} (${r.name}) must be remote`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('committed dataset sparsity rule: every pair of non-large rows is at least REMOTE_RADIUS_KM apart', async () => {
|
||||||
|
const text = await fs.readFile(path.join(process.cwd(), 'data', 'airports_world.csv'), 'utf8');
|
||||||
|
const rows = filterWorldAirports(parseCsv(text));
|
||||||
|
// v0.68.0 (ISS-144): specials are greedily thinned to one per ~REMOTE_RADIUS_KM region, so no
|
||||||
|
// two non-large rows may sit closer than the radius. n≈dozens makes O(n²) trivial.
|
||||||
|
const nonLarge = rows.filter((r) => r.type !== 'large_airport').map((r) => ({ icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg }));
|
||||||
|
expect(nonLarge.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < nonLarge.length; i++) {
|
||||||
|
for (let j = i + 1; j < nonLarge.length; j++) {
|
||||||
|
const d = haversineKm(nonLarge[i].lat, nonLarge[i].lon, nonLarge[j].lat, nonLarge[j].lon);
|
||||||
|
expect(d, `${nonLarge[i].icao} ↔ ${nonLarge[j].icao}: ${d.toFixed(1)} km < ${REMOTE_RADIUS_KM}`).toBeGreaterThanOrEqual(REMOTE_RADIUS_KM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+44
-5
@@ -1,5 +1,6 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { haversineKm } from './geo';
|
||||||
|
|
||||||
export type CsvRow = Record<string, string>;
|
export type CsvRow = Record<string, string>;
|
||||||
|
|
||||||
@@ -52,11 +53,36 @@ 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 by
|
||||||
|
// scripts/build-world-airports.mjs: every large_airport plus the small/medium/seaplane rows
|
||||||
|
// that are REMOTE (see isRemote) — 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.61.0 (ISS-097): "special" remote airports — the issue's loneliness test: an airport is
|
||||||
|
// remote when no other member of `airports` sits within this radius, so it stands alone in a
|
||||||
|
// genuinely empty region (Antarctica, high Arctic, far islands) rather than being one of many.
|
||||||
|
export const REMOTE_RADIUS_KM = 1000;
|
||||||
|
|
||||||
|
export interface RemoteAirport {
|
||||||
|
icao: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the airport with this ICAO has no OTHER member of `airports` within REMOTE_RADIUS_KM
|
||||||
|
// (haversine). Unknown ICACOs are not remote — callers only pass rows they intend to include.
|
||||||
|
export function isRemote(airports: readonly RemoteAirport[], icao: string): boolean {
|
||||||
|
const a = airports.find((x) => x.icao === icao);
|
||||||
|
if (!a) return false;
|
||||||
|
for (const b of airports) {
|
||||||
|
if (b.icao !== a.icao && haversineKm(a.lat, a.lon, b.lat, b.lon) <= REMOTE_RADIUS_KM) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AirportProperties {
|
export interface AirportProperties {
|
||||||
@@ -93,13 +119,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword, requireAuth, verifyPassword } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as
|
||||||
|
// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqWith = (sessionId?: string) =>
|
||||||
|
new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} });
|
||||||
|
|
||||||
|
describe('hashPassword / verifyPassword', () => {
|
||||||
|
it('round-trips a password through the stored digest', () => {
|
||||||
|
const hash = hashPassword('correct-horse-battery-staple');
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's
|
||||||
|
expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong password and malformed digests', () => {
|
||||||
|
const hash = hashPassword('some-password');
|
||||||
|
expect(verifyPassword('other-password', hash)).toBe(false);
|
||||||
|
expect(verifyPassword('x', 'not-a-digest')).toBe(false);
|
||||||
|
expect(verifyPassword('x', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAuth', () => {
|
||||||
|
it('401s when the header is missing or the session id is unknown', async () => {
|
||||||
|
for (const req of [reqWith(), reqWith('no-such-session')]) {
|
||||||
|
const guard = await requireAuth(req);
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) {
|
||||||
|
expect(guard.response.status).toBe(401);
|
||||||
|
expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401s an expired session', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]);
|
||||||
|
const guard = await requireAuth(reqWith('expired-sess'));
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) expect(guard.response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the seeded admin session to the local player row (joined on username)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const sessionId = await createSession(inst, 'admin-kamilkrzywda');
|
||||||
|
const guard = await requireAuth(reqWith(sessionId));
|
||||||
|
expect(guard.ok).toBe(true);
|
||||||
|
if (guard.ok) {
|
||||||
|
expect(guard.user.username).toBe('kamilkrzywda');
|
||||||
|
expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row
|
||||||
|
expect(guard.player.cash).toBe(20_000); // starter economy from the seed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500s when the account has no players row (broken data, not a client error)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [
|
||||||
|
hashPassword('whatever-123'),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
const sessionId = await createSession(inst, 'user-no-player');
|
||||||
|
const guard = await requireAuth(reqWith(sessionId));
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) {
|
||||||
|
expect(guard.response.status).toBe(500);
|
||||||
|
expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
|
import type { PlayerRow } from './db/schema';
|
||||||
|
|
||||||
|
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
|
||||||
|
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
|
||||||
|
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
|
||||||
|
// a single-origin dev tool surface, not a public auth system.
|
||||||
|
|
||||||
|
export const SESSION_HEADER = 'x-flight-session';
|
||||||
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
|
||||||
|
|
||||||
|
// Fixed digest compared against when the username is unknown, so every failed login runs one
|
||||||
|
// full scrypt and the timing shape stays uniform (no user-existence oracle).
|
||||||
|
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
|
||||||
|
// 32-byte hash). Malformed digests never match.
|
||||||
|
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||||
|
const [saltHex, hashHex] = storedHash.split('$');
|
||||||
|
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
|
||||||
|
const computed = scryptSync(password, saltHex, 32).toString('hex');
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
|
||||||
|
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
|
||||||
|
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
|
||||||
|
// verifies through verifyPassword().
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
||||||
|
// doesn't silently log everyone out mid-session.
|
||||||
|
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
|
||||||
|
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
Date.now() + SESSION_TTL_MS,
|
||||||
|
]);
|
||||||
|
await persist(inst);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a session id to a live (non-expired) user, or null.
|
||||||
|
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
|
||||||
|
const res = inst.raw.exec(
|
||||||
|
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
|
||||||
|
[sessionId, Date.now()],
|
||||||
|
);
|
||||||
|
const row = res[0]?.values?.[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
||||||
|
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
||||||
|
const sessionId = req.headers.get(SESSION_HEADER);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return resolveSession(await getGameDb(), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthGuard =
|
||||||
|
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
|
||||||
|
| { ok: false; response: Response };
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
|
||||||
|
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
|
||||||
|
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
|
||||||
|
// (register and the mint hook always create both together — a miss means broken data).
|
||||||
|
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
|
||||||
|
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
|
||||||
|
const row = res[0]?.values?.[0];
|
||||||
|
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
inst,
|
||||||
|
user,
|
||||||
|
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req: Request): Promise<AuthGuard> {
|
||||||
|
return finishAuth(await currentUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
|
||||||
|
// ?session= query parameter (private infra; every other game route is header-only).
|
||||||
|
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
|
||||||
|
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
|
||||||
|
if (!sessionId) return finishAuth(null);
|
||||||
|
return finishAuth(await resolveSession(await getGameDb(), sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
|
||||||
|
|
||||||
|
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
|
||||||
|
// No/expired session → 401, valid non-admin session → 403.
|
||||||
|
export async function requireAdmin(req: Request): Promise<AdminGuard> {
|
||||||
|
const user = await currentUser(req);
|
||||||
|
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||||
|
if (!user.admin) {
|
||||||
|
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { ok: true, inst: await getGameDb(), user };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — client-side session handling shared by every authenticated fetcher, the
|
||||||
|
// sign-in screen and the admin panel. The server mints sessions at /api/login + /api/register;
|
||||||
|
// we only store the id in localStorage and echo it back as X-Flight-Session.
|
||||||
|
|
||||||
|
export const SESSION_STORAGE_KEY = 'flight.session';
|
||||||
|
|
||||||
|
export function storedSession(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null; // storage blocked (private mode) — treat as signed out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers for an authenticated fetch. Empty object when no session is stored, so callers can
|
||||||
|
// spread it into their existing header objects without branching.
|
||||||
|
export function sessionHeaders(session: string | null = storedSession()): Record<string, string> {
|
||||||
|
return session ? { 'X-Flight-Session': session } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeSession(sessionId: string): void {
|
||||||
|
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredSession(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// storage blocked — nothing to clear
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
-13
@@ -2,10 +2,17 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import initSqlJs from 'sql.js';
|
import initSqlJs from 'sql.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
|
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
import { openGameDb, persist } from './client';
|
import { openGameDb, persist } from './client';
|
||||||
|
import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
// v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry.
|
||||||
|
const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' };
|
||||||
|
|
||||||
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
|
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
|
||||||
const LEGACY_DDL = `
|
const LEGACY_DDL = `
|
||||||
CREATE TABLE planes (
|
CREATE TABLE planes (
|
||||||
@@ -82,6 +89,40 @@ CREATE TABLE sim_state (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
|
||||||
|
// file for the owner_id backfill migration test.
|
||||||
|
const PRE_OWNERSHIP_DDL = `
|
||||||
|
CREATE TABLE planes (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
fuel REAL NOT NULL,
|
||||||
|
max_fuel REAL NOT NULL,
|
||||||
|
location_icao TEXT NOT NULL,
|
||||||
|
dest_icao TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
departure_sim_time INTEGER,
|
||||||
|
queue TEXT NOT NULL DEFAULT '[]',
|
||||||
|
health REAL NOT NULL DEFAULT 100,
|
||||||
|
repair_until_sim_time INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE players (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
cash REAL NOT NULL,
|
||||||
|
reputation REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE sim_state (
|
||||||
|
clock_ms INTEGER NOT NULL,
|
||||||
|
time_scale REAL NOT NULL,
|
||||||
|
paused INTEGER NOT NULL,
|
||||||
|
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -108,11 +149,17 @@ describe('openGameDb', () => {
|
|||||||
expect(packages.length).toBeGreaterThanOrEqual(8);
|
expect(packages.length).toBeGreaterThanOrEqual(8);
|
||||||
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
// v1.0.0 (ISS-105): the singleton economy is now a seeded local player row…
|
||||||
expect(account?.cash).toBeGreaterThan(0);
|
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
|
// …and fresh DBs never create an `account` table.
|
||||||
|
const tables = new Set(
|
||||||
|
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||||
|
);
|
||||||
|
expect(tables.has('account')).toBe(false);
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
expect(sim?.paused).toBe(false);
|
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
|
||||||
expect(sim?.timeScale).toBe(1);
|
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
|
||||||
|
expect(sim?.timeScale).toBe(WORLD_TIME_SCALE);
|
||||||
|
|
||||||
// The seed was durably written: the file exists and is non-empty.
|
// The seed was durably written: the file exists and is non-empty.
|
||||||
const st = await fs.stat(file);
|
const st = await fs.stat(file);
|
||||||
@@ -123,25 +170,25 @@ describe('openGameDb', () => {
|
|||||||
const file = path.join(dir, 'reopen.db');
|
const file = path.join(dir, 'reopen.db');
|
||||||
const first = await openGameDb(file);
|
const first = await openGameDb(file);
|
||||||
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||||
const account1 = (await first.db.select().from(schema.account))[0];
|
const player1 = await getLocalPlayer(first);
|
||||||
|
|
||||||
// Simulate a process restart: brand-new instance over the same file.
|
// Simulate a process restart: brand-new instance over the same file.
|
||||||
const second = await openGameDb(file);
|
const second = await openGameDb(file);
|
||||||
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||||
expect(packages2).toEqual(packages1);
|
expect(packages2).toEqual(packages1);
|
||||||
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
expect(await getLocalPlayer(second)).toEqual(player1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persist() durably saves writes made after open', async () => {
|
it('persist() durably saves writes made after open', async () => {
|
||||||
const file = path.join(dir, 'write.db');
|
const file = path.join(dir, 'write.db');
|
||||||
const inst = await openGameDb(file);
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
await inst.db.update(schema.account).set({ cash: 12345 });
|
await updateLocalPlayerEconomy(inst, { cash: 12345 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|
||||||
// A fresh instance over the same file sees the update.
|
// A fresh instance over the same file sees the update.
|
||||||
const reloaded = await openGameDb(file);
|
const reloaded = await openGameDb(file);
|
||||||
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
|
expect((await getLocalPlayer(reloaded)).cash).toBe(12345);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
|
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
|
||||||
@@ -151,14 +198,15 @@ describe('openGameDb', () => {
|
|||||||
raw.exec(LEGACY_DDL);
|
raw.exec(LEGACY_DDL);
|
||||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)');
|
// time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale.
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||||
const file = path.join(dir, 'legacy.db');
|
const file = path.join(dir, 'legacy.db');
|
||||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
const inst = await openGameDb(file);
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
// Not re-seeded (account row exists)…
|
// Not re-seeded (the legacy account row was migrated into the local player)…
|
||||||
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
|
expect((await getLocalPlayer(inst)).cash).toBe(20000);
|
||||||
// …but the new columns exist and are usable…
|
// …but the new columns exist and are usable…
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane?.departureSimTime ?? null).toBeNull();
|
expect(plane?.departureSimTime ?? null).toBeNull();
|
||||||
@@ -167,6 +215,9 @@ describe('openGameDb', () => {
|
|||||||
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
|
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
|
||||||
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
||||||
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
||||||
|
// v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default.
|
||||||
|
expect(sim?.timeScale).toBe(50);
|
||||||
|
expect(sim?.lastSelectedTimeScale).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
||||||
@@ -187,8 +238,8 @@ describe('openGameDb', () => {
|
|||||||
|
|
||||||
const inst = await openGameDb(file);
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
// Wallet + sim clock survive byte-identical…
|
// Wallet (migrated into the local player) + sim clock survive byte-identical…
|
||||||
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
|
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||||
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
||||||
|
|
||||||
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
||||||
@@ -215,6 +266,77 @@ describe('openGameDb', () => {
|
|||||||
expect(cols.has('dest_icao')).toBe(true);
|
expect(cols.has('dest_icao')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
|
||||||
|
const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
|
||||||
|
// The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(),
|
||||||
|
// so it never verified; production DBs seeded by that version carry exactly this value.
|
||||||
|
const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
|
||||||
|
|
||||||
|
it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
|
||||||
|
const file = path.join(dir, 'admin-seed.db');
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
|
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||||
|
expect(rows[0].values).toHaveLength(1);
|
||||||
|
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
|
||||||
|
|
||||||
|
// Simulate a process restart: brand-new instance over the same file.
|
||||||
|
const second = await openGameDb(file);
|
||||||
|
rows = second.raw.exec('SELECT COUNT(*) FROM users');
|
||||||
|
expect(Number(rows[0].values[0][0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the admin into a legacy DB that predates the users table', async () => {
|
||||||
|
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(LEGACY_DDL);
|
||||||
|
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||||
|
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||||
|
const file = path.join(dir, 'legacy-admin.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||||
|
expect(rows[0].values).toHaveLength(1);
|
||||||
|
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
|
||||||
|
// The game world is untouched by the seed (its account row migrated to the local player).
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => {
|
||||||
|
// A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest.
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(`CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
|
raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [
|
||||||
|
'admin-kamilkrzywda',
|
||||||
|
'kamilkrzywda',
|
||||||
|
LEGACY_HASH,
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
const file = path.join(dir, 'admin-rotate.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
// First open heals the row to the current digest…
|
||||||
|
const first = await openGameDb(file);
|
||||||
|
let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
|
||||||
|
expect(rows[0].values).toHaveLength(1);
|
||||||
|
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
|
||||||
|
|
||||||
|
// …and a second open is a no-op (idempotent rotation).
|
||||||
|
const second = await openGameDb(file);
|
||||||
|
rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
|
||||||
|
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
||||||
const file = path.join(dir, 'pre-v12-reopen.db');
|
const file = path.join(dir, 'pre-v12-reopen.db');
|
||||||
const SQL = await initSqlJs();
|
const SQL = await initSqlJs();
|
||||||
@@ -237,4 +359,58 @@ describe('openGameDb', () => {
|
|||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
expect(plane.destIcao).toBe('LEMD');
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => {
|
||||||
|
// Minimal legacy file — just an `account` table; open() creates everything else from DDL.
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`);
|
||||||
|
raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`);
|
||||||
|
const file = path.join(dir, 'pre-v1.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
|
||||||
|
let tables = new Set(
|
||||||
|
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||||
|
);
|
||||||
|
expect(tables.has('account')).toBe(false);
|
||||||
|
|
||||||
|
// Second open is a no-op (idempotent): the row survives unchanged, still exactly one player.
|
||||||
|
const second = await openGameDb(file);
|
||||||
|
expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
|
||||||
|
tables = new Set(
|
||||||
|
second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||||
|
);
|
||||||
|
expect(tables.has('account')).toBe(false);
|
||||||
|
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
|
||||||
|
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(PRE_OWNERSHIP_DDL);
|
||||||
|
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
|
||||||
|
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
|
||||||
|
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
|
||||||
|
const file = path.join(dir, 'pre-ownership.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
|
// Every legacy plane is backfilled to the local player…
|
||||||
|
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||||
|
expect(owners).toEqual(['local', 'local']);
|
||||||
|
// …and the world was NOT re-seeded (the players row already existed).
|
||||||
|
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||||
|
|
||||||
|
// Second open is a no-op: an owner change made after the migration survives a reopen.
|
||||||
|
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
|
||||||
|
await persist(inst);
|
||||||
|
const second = await openGameDb(file);
|
||||||
|
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||||
|
expect(owners2).toEqual(['local', 'someone-else']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+122
-13
@@ -5,6 +5,7 @@ import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
|
|||||||
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
|
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { buildSeed, mulberry32 } from '@/lib/game/seed';
|
import { buildSeed, mulberry32 } from '@/lib/game/seed';
|
||||||
|
import { LOCAL_PLAYER } from './localPlayer';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
|
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
|
||||||
@@ -22,6 +23,9 @@ export interface GameDb {
|
|||||||
const DDL = `
|
const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS planes (
|
CREATE TABLE IF NOT EXISTS planes (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
|
||||||
|
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
fuel REAL NOT NULL,
|
fuel REAL NOT NULL,
|
||||||
@@ -31,7 +35,9 @@ CREATE TABLE IF NOT EXISTS planes (
|
|||||||
lat REAL NOT NULL,
|
lat REAL NOT NULL,
|
||||||
lon REAL NOT NULL,
|
lon REAL NOT NULL,
|
||||||
departure_sim_time INTEGER,
|
departure_sim_time INTEGER,
|
||||||
queue TEXT NOT NULL DEFAULT '[]'
|
queue TEXT NOT NULL DEFAULT '[]',
|
||||||
|
health REAL NOT NULL DEFAULT 100,
|
||||||
|
repair_until_sim_time INTEGER
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS packages (
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -41,12 +47,20 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
cargo_type TEXT NOT NULL,
|
cargo_type TEXT NOT NULL,
|
||||||
cargo_count INTEGER NOT NULL,
|
cargo_count INTEGER NOT NULL,
|
||||||
weight_kg REAL NOT NULL DEFAULT 2000,
|
weight_kg REAL NOT NULL DEFAULT 2000,
|
||||||
|
special TEXT,
|
||||||
deadline_sim_time INTEGER NOT NULL,
|
deadline_sim_time INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
plane_id TEXT,
|
plane_id TEXT,
|
||||||
created_sim_time INTEGER NOT NULL
|
created_sim_time INTEGER NOT NULL,
|
||||||
|
created_real_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
slot_free_at_real_ms INTEGER
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS account (
|
-- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local
|
||||||
|
-- single-player row is seeded by seedEmpty() or migrated from account in open().
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
cash REAL NOT NULL,
|
cash REAL NOT NULL,
|
||||||
reputation REAL NOT NULL
|
reputation REAL NOT NULL
|
||||||
);
|
);
|
||||||
@@ -54,10 +68,49 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
|||||||
clock_ms INTEGER NOT NULL,
|
clock_ms INTEGER NOT NULL,
|
||||||
time_scale REAL NOT NULL,
|
time_scale REAL NOT NULL,
|
||||||
paused INTEGER NOT NULL,
|
paused INTEGER NOT NULL,
|
||||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the
|
||||||
|
// operator's head — never in code, tests or comments; login verifies against this string.
|
||||||
|
// v0.72.0: recomputed with exactly the same call as verifyPassword() — salt passed as a
|
||||||
|
// plain string, scryptSync(pw, saltHex, 32) — because the v0.71.0 digest used different
|
||||||
|
// parameters and real logins failed with 401.
|
||||||
|
const ADMIN_SEED_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
|
||||||
|
|
||||||
|
// v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so
|
||||||
|
// pre-existing production DBs gain exactly one admin row and never a duplicate.
|
||||||
|
function seedAdminUser(raw: RawSqlJsDatabase): void {
|
||||||
|
raw.run(
|
||||||
|
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||||
|
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
|
||||||
|
[ADMIN_SEED_HASH, Date.now()],
|
||||||
|
);
|
||||||
|
// v0.72.0: v0.71.0 seeded an incompatible digest into production DBs and the INSERT above
|
||||||
|
// can't overwrite them (WHERE NOT EXISTS), so rotate any legacy row to the current digest
|
||||||
|
// at boot — a no-op once healed, durable on the next persist().
|
||||||
|
raw.run(
|
||||||
|
`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`,
|
||||||
|
[ADMIN_SEED_HASH, ADMIN_SEED_HASH],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||||
// already have everything from DDL above and this is a no-op for them.
|
// already have everything from DDL above and this is a no-op for them.
|
||||||
@@ -82,6 +135,39 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('packages').has('weight_kg')) {
|
if (!cols('packages').has('weight_kg')) {
|
||||||
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
||||||
}
|
}
|
||||||
|
// v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
|
||||||
|
if (!cols('packages').has('special')) {
|
||||||
|
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
||||||
|
}
|
||||||
|
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
|
||||||
|
if (!cols('planes').has('health')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
|
||||||
|
}
|
||||||
|
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
|
||||||
|
if (!cols('planes').has('repair_until_sim_time')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
||||||
|
}
|
||||||
|
// v0.65.0: real-time creation stamp — legacy rows backfill to NOW so they get a fresh full lifetime
|
||||||
|
// instead of expiring on the first tick (created_real_ms = 0 is far in the past).
|
||||||
|
if (!cols('packages').has('created_real_ms')) {
|
||||||
|
raw.exec(`ALTER TABLE packages ADD COLUMN created_real_ms INTEGER NOT NULL DEFAULT ${Date.now()}`);
|
||||||
|
}
|
||||||
|
// v0.65.0: per-(origin, band) refill cooldown — nullable; legacy rows stay NULL = no cooldown.
|
||||||
|
if (!cols('packages').has('slot_free_at_real_ms')) {
|
||||||
|
raw.exec('ALTER TABLE packages ADD COLUMN slot_free_at_real_ms INTEGER');
|
||||||
|
}
|
||||||
|
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||||
|
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||||
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||||
|
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
||||||
|
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
||||||
|
}
|
||||||
|
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
|
||||||
|
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
|
||||||
|
// constant default, which SQLite applies to every existing row at ALTER time).
|
||||||
|
if (!cols('planes').has('owner_id')) {
|
||||||
|
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||||
@@ -111,6 +197,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
|
|||||||
raw.exec('DROP TABLE contracts');
|
raw.exec('DROP TABLE contracts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop
|
||||||
|
// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have
|
||||||
|
// `account`, so this runs exactly once per legacy file and is a no-op on later opens.
|
||||||
|
function migrateAccountToPlayers(raw: RawSqlJsDatabase): void {
|
||||||
|
raw.run(
|
||||||
|
'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account',
|
||||||
|
[LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username],
|
||||||
|
);
|
||||||
|
raw.exec('DROP TABLE account');
|
||||||
|
}
|
||||||
|
|
||||||
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
||||||
// of the real ./db/flight.db.
|
// of the real ./db/flight.db.
|
||||||
export function defaultDbPath(): string {
|
export function defaultDbPath(): string {
|
||||||
@@ -128,6 +225,7 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||||
migrateColumns(raw);
|
migrateColumns(raw);
|
||||||
|
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
|
||||||
|
|
||||||
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
||||||
// without this it would keep the dead contracts table forever.
|
// without this it would keep the dead contracts table forever.
|
||||||
@@ -137,12 +235,19 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
if (tables.has('contracts')) {
|
if (tables.has('contracts')) {
|
||||||
migrateContractsToPackages(raw);
|
migrateContractsToPackages(raw);
|
||||||
}
|
}
|
||||||
|
// v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a
|
||||||
|
// seeded local player and drop it. The players-empty guard makes this idempotent even if a file
|
||||||
|
// somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file.
|
||||||
|
const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0);
|
||||||
|
if (tables.has('account') && playerCount === 0) {
|
||||||
|
migrateAccountToPlayers(raw);
|
||||||
|
}
|
||||||
|
|
||||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||||
|
|
||||||
// First-run seed: only when the DB is empty (no account row yet).
|
// First-run seed: only when the DB is empty (no player row yet).
|
||||||
const res = raw.exec('SELECT COUNT(*) FROM account');
|
if (playerCount === 0 && !tables.has('account')) {
|
||||||
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
|
||||||
await seedEmpty(instance);
|
await seedEmpty(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +276,11 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
|||||||
|
|
||||||
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||||
const { queue, ...planeCols } = data.plane;
|
const { queue, ...planeCols } = data.plane;
|
||||||
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
|
// v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
|
||||||
|
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
|
||||||
await instance.db.insert(schema.packages).values(data.packages);
|
await instance.db.insert(schema.packages).values(data.packages);
|
||||||
await instance.db.insert(schema.account).values(data.account);
|
// v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
|
||||||
|
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
|
||||||
await instance.db.insert(schema.simState).values(data.simState);
|
await instance.db.insert(schema.simState).values(data.simState);
|
||||||
await persist(instance);
|
await persist(instance);
|
||||||
}
|
}
|
||||||
@@ -188,12 +295,14 @@ export async function persist(instance: GameDb): Promise<void> {
|
|||||||
await fs.rename(tmp, instance.file);
|
await fs.rename(tmp, instance.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton for the app process; openGameDb() is the uncached variant (tests).
|
// Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0):
|
||||||
let ready: Promise<GameDb> | null = null;
|
// lives on globalThis because Next.js can load this module more than once per process —
|
||||||
|
// instrumentation.ts and route handlers get separate module instances, so a plain `let` would give
|
||||||
|
// each its own in-memory sql.js DB (both persisting to the same file). One world per process.
|
||||||
|
const g = globalThis as unknown as { __flightGameDb?: Promise<GameDb> };
|
||||||
|
|
||||||
export function getGameDb(): Promise<GameDb> {
|
export function getGameDb(): Promise<GameDb> {
|
||||||
if (!ready) ready = open(defaultDbPath());
|
return (g.__flightGameDb ??= open(defaultDbPath()));
|
||||||
return ready;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openGameDb(file: string): Promise<GameDb> {
|
export async function openGameDb(file: string): Promise<GameDb> {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { eq, max } from 'drizzle-orm';
|
||||||
|
import type { GameDb } from './client';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
||||||
|
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
|
||||||
|
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
|
||||||
|
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
|
||||||
|
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
|
||||||
|
|
||||||
|
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||||
|
|
||||||
|
export async function getLocalPlayer(inst: GameDb): Promise<schema.PlayerRow> {
|
||||||
|
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||||
|
if (!row) throw new Error('local player row missing');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalPlayerEconomy(
|
||||||
|
inst: GameDb,
|
||||||
|
patch: { cash?: number; reputation?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and
|
||||||
|
// session-scoped snapshots thread the acting player's id through and read/write THAT row; the
|
||||||
|
// 'local' helpers above are reserved for world-level reads with no acting session.
|
||||||
|
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
|
||||||
|
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
|
||||||
|
if (!row) throw new Error(`player ${id} missing`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlayerEconomy(
|
||||||
|
inst: GameDb,
|
||||||
|
id: string,
|
||||||
|
patch: { cash?: number; reputation?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
|
||||||
|
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
|
||||||
|
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
|
||||||
|
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
|
||||||
|
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
|
||||||
|
return row?.m ?? 0;
|
||||||
|
}
|
||||||
+55
-3
@@ -6,8 +6,14 @@ 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(),
|
||||||
|
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
|
||||||
|
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
|
||||||
|
// onboarding stamp it explicitly on insert.
|
||||||
|
ownerId: text('owner_id').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 +30,12 @@ export const planes = sqliteTable('planes', {
|
|||||||
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||||
queue: text('queue').notNull().default('[]'),
|
queue: text('queue').notNull().default('[]'),
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
||||||
|
// landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
|
||||||
|
health: real('health').notNull().default(100),
|
||||||
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
||||||
|
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
||||||
|
repairUntilSimTime: integer('repair_until_sim_time'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||||
@@ -40,15 +52,31 @@ export const packages = sqliteTable('packages', {
|
|||||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
|
||||||
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
|
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
|
||||||
weightKg: real('weight_kg').notNull().default(2000),
|
weightKg: real('weight_kg').notNull().default(2000),
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
|
||||||
|
special: text('special'),
|
||||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||||
planeId: text('plane_id'),
|
planeId: text('plane_id'),
|
||||||
createdSimTime: integer('created_sim_time').notNull(),
|
createdSimTime: integer('created_sim_time').notNull(),
|
||||||
|
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
|
||||||
|
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
|
||||||
|
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
|
||||||
|
createdRealMs: integer('created_real_ms').notNull().default(0),
|
||||||
|
// Real-time epoch ms when this (origin, band) slot was last taken — loaded (v0.65.0). The pool
|
||||||
|
// spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open
|
||||||
|
// jobs set nothing: an untaken job's slot respawns immediately.
|
||||||
|
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const account = sqliteTable('account', {
|
// v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local
|
||||||
|
// single-player row (id 'local') is seeded on first run / migrated from `account` by open();
|
||||||
|
// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only.
|
||||||
|
export const players = sqliteTable('players', {
|
||||||
|
id: text('id').primaryKey().notNull(),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
username: text('username').notNull(),
|
||||||
cash: real('cash').notNull(),
|
cash: real('cash').notNull(),
|
||||||
reputation: real('reputation').notNull(),
|
reputation: real('reputation').notNull(),
|
||||||
});
|
});
|
||||||
@@ -60,9 +88,33 @@ export const simState = sqliteTable('sim_state', {
|
|||||||
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
|
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
|
||||||
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
|
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
|
||||||
lastRealTickMs: integer('last_real_tick_ms').notNull(),
|
lastRealTickMs: integer('last_real_tick_ms').notNull(),
|
||||||
|
// Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without
|
||||||
|
// touching this; flyPlane restores it when the last grounded plane takes off. Written by
|
||||||
|
// POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale.
|
||||||
|
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||||
|
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey().notNull(),
|
||||||
|
username: text('username').notNull().unique(),
|
||||||
|
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = sqliteTable('sessions', {
|
||||||
|
id: text('id').primaryKey().notNull(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
|
||||||
|
expiresAt: integer('expires_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PlaneRow = typeof planes.$inferSelect;
|
export type PlaneRow = typeof planes.$inferSelect;
|
||||||
export type PackageRow = typeof packages.$inferSelect;
|
export type PackageRow = typeof packages.$inferSelect;
|
||||||
export type AccountRow = typeof account.$inferSelect;
|
export type PlayerRow = typeof players.$inferSelect;
|
||||||
export type SimStateRow = typeof simState.$inferSelect;
|
export type SimStateRow = typeof simState.$inferSelect;
|
||||||
|
export type UserRow = typeof users.$inferSelect;
|
||||||
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
|
|||||||
+14
-13
@@ -22,7 +22,11 @@ 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: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePackage = (id: string): PackageState => ({
|
const basePackage = (id: string): PackageState => ({
|
||||||
@@ -34,17 +38,22 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
reward: 1234.5,
|
reward: 1234.5,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
weightKg: 600,
|
weightKg: 600,
|
||||||
deadlineSimTime: 0,
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
|
expiresRealMs: null, // v0.65.0 — wall-clock expiry; not exercised here
|
||||||
|
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||||
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0 },
|
||||||
planes: [basePlane('plane-starter')],
|
planes: [basePlane('plane-starter')],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
@@ -55,11 +64,11 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
|||||||
describe('diffSnapshots', () => {
|
describe('diffSnapshots', () => {
|
||||||
it('returns [] for identical snapshots (only clock/cash drift)', () => {
|
it('returns [] for identical snapshots (only clock/cash drift)', () => {
|
||||||
const prev = snapshot();
|
const prev = snapshot();
|
||||||
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } });
|
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000 } });
|
||||||
expect(diffSnapshots(prev, next)).toEqual([]);
|
expect(diffSnapshots(prev, next)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => {
|
it('returns [] for the first snapshot (no baseline to diff against)', () => {
|
||||||
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
|
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
|
||||||
expect(diffSnapshots(null, next)).toEqual([]);
|
expect(diffSnapshots(null, next)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -108,14 +117,6 @@ describe('diffSnapshots', () => {
|
|||||||
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
|
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits a new-package event for ids only in the next snapshot', () => {
|
|
||||||
const prev = snapshot({ packages: [basePackage('pkg-1')] });
|
|
||||||
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
|
|
||||||
expect(diffSnapshots(prev, next)).toEqual([
|
|
||||||
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits bought/sold events when the plane id set changes (with the model name)', () => {
|
it('emits bought/sold events when the plane id set changes (with the model name)', () => {
|
||||||
const bought = diffSnapshots(
|
const bought = diffSnapshots(
|
||||||
snapshot(),
|
snapshot(),
|
||||||
|
|||||||
+4
-9
@@ -15,8 +15,7 @@ export interface GameEvent {
|
|||||||
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
|
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
|
||||||
|
|
||||||
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
|
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
|
||||||
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a
|
// No baseline yet (first snapshot after load) — nothing to diff against, so no events.
|
||||||
// "new package" event for every existing row would spam the stack on page load.
|
|
||||||
if (prev === null) return [];
|
if (prev === null) return [];
|
||||||
|
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
@@ -40,16 +39,12 @@ export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired
|
// Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
|
||||||
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never
|
// (open|loaded → expired). Ids that appear for the first time emit nothing.
|
||||||
// deleted, so an id appearing is always a genuinely new one).
|
|
||||||
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
|
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
|
||||||
for (const pkg of next.packages) {
|
for (const pkg of next.packages) {
|
||||||
const before = prevPackages.get(pkg.id);
|
const before = prevPackages.get(pkg.id);
|
||||||
if (!before) {
|
if (!before) continue;
|
||||||
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (before.status === 'loaded' && pkg.status === 'delivered') {
|
if (before.status === 'loaded' && pkg.status === 'delivered') {
|
||||||
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
|
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
|
||||||
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
|
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format';
|
||||||
|
|
||||||
describe('formatCash', () => {
|
describe('formatCash', () => {
|
||||||
it('formats EUR with thousands separators and two decimals', () => {
|
it('formats EUR with thousands separators and two decimals', () => {
|
||||||
@@ -21,6 +21,23 @@ describe('formatFuel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatCargo', () => {
|
||||||
|
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
|
||||||
|
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
|
||||||
|
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
|
||||||
|
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
|
||||||
|
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatKm', () => {
|
||||||
|
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
|
||||||
|
expect(formatKm(554)).toBe('554km');
|
||||||
|
expect(formatKm(214.6)).toBe('215km');
|
||||||
|
expect(formatKm(0)).toBe('0km');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatSimClock', () => {
|
describe('formatSimClock', () => {
|
||||||
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
||||||
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ export function formatFuel(fuel: number, maxFuel: number): string {
|
|||||||
return `${Math.round(fuel)} / ${maxFuel} L`;
|
return `${Math.round(fuel)} / ${maxFuel} L`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
|
||||||
|
export function formatCargo(cargoType: string, weightKg: number): string {
|
||||||
|
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
|
||||||
|
export function formatKm(km: number): string {
|
||||||
|
return `${Math.round(km)}km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
|
||||||
|
export function formatSpecialLabel(special: string): string {
|
||||||
|
return special.charAt(0).toUpperCase() + special.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
||||||
// seconds so the display is deterministic regardless of the viewer's timezone.
|
// seconds so the display is deterministic regardless of the viewer's timezone.
|
||||||
export function formatSimClock(clockMs: number): string {
|
export function formatSimClock(clockMs: number): string {
|
||||||
|
|||||||
+406
-199
@@ -5,11 +5,24 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
import { HUB_ICAO } from './seed';
|
import {
|
||||||
|
FUEL_DISCOUNT_PER_REP,
|
||||||
|
FUEL_PRICE_PER_L,
|
||||||
|
MAX_FUEL_DISCOUNT,
|
||||||
|
MAX_PLANE_DISCOUNT,
|
||||||
|
PLANE_DISCOUNT_PER_REP,
|
||||||
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
|
repRate,
|
||||||
|
SELL_FRACTION,
|
||||||
|
} from './catalog';
|
||||||
|
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
@@ -18,7 +31,7 @@ import {
|
|||||||
GameActionError,
|
GameActionError,
|
||||||
loadPackage,
|
loadPackage,
|
||||||
queueLeg,
|
queueLeg,
|
||||||
refuelPlane,
|
repairPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
@@ -81,7 +94,7 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb('load-ok.db');
|
const inst = await freshDb('load-ok.db');
|
||||||
await insertOpenPackage(inst);
|
await insertOpenPackage(inst);
|
||||||
|
|
||||||
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
|
||||||
|
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||||
expect(row.status).toBe('loaded');
|
expect(row.status).toBe('loaded');
|
||||||
@@ -94,19 +107,19 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb(`load-${status}.db`);
|
const inst = await freshDb(`load-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rejects an unknown package id with 404', async () => {
|
it('rejects an unknown package id with 404', async () => {
|
||||||
const inst = await freshDb('load-punknown.db');
|
const inst = await freshDb('load-punknown.db');
|
||||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('load-ghost.db');
|
const inst = await freshDb('load-ghost.db');
|
||||||
await insertOpenPackage(inst);
|
await insertOpenPackage(inst);
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
@@ -116,7 +129,7 @@ describe('loadPackage', () => {
|
|||||||
.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(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a plane that is not at the origin with 409', async () => {
|
it('rejects a plane that is not at the origin with 409', async () => {
|
||||||
@@ -124,7 +137,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst); // originates at EDDF
|
await insertOpenPackage(inst); // originates at EDDF
|
||||||
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
||||||
@@ -136,7 +149,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
||||||
}
|
}
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts expired packages as occupying their payload too', async () => {
|
it('counts expired packages as occupying their payload too', async () => {
|
||||||
@@ -147,7 +160,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||||||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,12 +168,12 @@ describe('flyPlane', () => {
|
|||||||
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||||
const inst = await freshDb('fly-ok.db');
|
const inst = await freshDb('fly-ok.db');
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
expect(plane.destIcao).toBe('LEMD');
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), no tick in direct calls
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
expect(plane.lat).toBe(origin.lat);
|
expect(plane.lat).toBe(origin.lat);
|
||||||
@@ -169,42 +182,154 @@ describe('flyPlane', () => {
|
|||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => {
|
|
||||||
const inst = await freshDb('fly-unpause.db');
|
|
||||||
// The last landing pauses the sim at whatever scale was in use.
|
|
||||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 });
|
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
|
||||||
|
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
||||||
expect(sim.paused).toBe(false); // dispatch auto-resumes…
|
|
||||||
expect(sim.timeScale).toBe(250); // …with the stored speed preserved
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('fly-flying.db');
|
const inst = await freshDb('fly-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(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('fly-ghost.db');
|
const inst = await freshDb('fly-ghost.db');
|
||||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('fly-unknownicao.db');
|
const inst = await freshDb('fly-unknownicao.db');
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 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 getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 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 getLocalPlayer(inst)).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', 'local'); // 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 updateLocalPlayerEconomy(inst, { reputation: 30 });
|
||||||
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
|
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 player = await getLocalPlayer(inst);
|
||||||
|
expect(player.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 getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
|
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 getLocalPlayer(inst)).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 updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 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', 'local'), 409, /insufficient fuel/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||||
|
|
||||||
|
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||||
|
const inst = await freshDb('fly-consume.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||||
|
const inst = await freshDb('fly-skipahead.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('EHAM');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,11 +337,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
it('appends legs to the queue in order and persists them as JSON', async () => {
|
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||||
const inst = await freshDb('qleg-ok.db');
|
const inst = await freshDb('qleg-ok.db');
|
||||||
|
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||||
|
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
|
||||||
plane = (await inst.db.select().from(schema.planes))[0];
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||||||
});
|
});
|
||||||
@@ -227,7 +352,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
.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 queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying'); // still on its active leg…
|
expect(plane.status).toBe('flying'); // still on its active leg…
|
||||||
@@ -237,23 +362,54 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qleg-ghost.db');
|
const inst = await freshDb('qleg-ghost.db');
|
||||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('qleg-unknownicao.db');
|
const inst = await freshDb('qleg-unknownicao.db');
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dup.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupflying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupother.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||||
const inst = await freshDb('qcancel-ok.db');
|
const inst = await freshDb('qcancel-ok.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
|
||||||
|
|
||||||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||||||
@@ -261,32 +417,32 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
|||||||
|
|
||||||
it('rejects an out-of-range index with 400', async () => {
|
it('rejects an out-of-range index with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-oob.db');
|
const inst = await freshDb('qcancel-oob.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cancelling from an empty queue with 400', async () => {
|
it('rejects cancelling from an empty queue with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qcancel-ghost.db');
|
const inst = await freshDb('qcancel-ghost.db');
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('abandonPackage', () => {
|
describe('abandonPackage', () => {
|
||||||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
// A loaded package created at the seeded game clock with a 10h window (ISS-117).
|
||||||
const REWARD = 1_000;
|
const REWARD = 1_000;
|
||||||
const WINDOW_MS = 10 * 3_600_000;
|
const WINDOW_MS = 10 * 3_600_000;
|
||||||
|
|
||||||
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
||||||
const inst = await freshDb(name);
|
const inst = await freshDb(name);
|
||||||
// Always insert OPEN and load it through the real action…
|
// Always insert OPEN and load it through the real action…
|
||||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
|
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
||||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
await loadPackage(inst, 'p-ab', 'plane-starter', 'local');
|
||||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||||
if (status === 'expired') {
|
if (status === 'expired') {
|
||||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||||||
@@ -296,60 +452,60 @@ describe('abandonPackage', () => {
|
|||||||
|
|
||||||
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
|
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
|
||||||
const inst = await setupLoaded('ab-start.db');
|
const inst = await setupLoaded('ab-start.db');
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
expect(row.status).toBe('abandoned');
|
expect(row.status).toBe('abandoned');
|
||||||
expect(row.planeId).toBeNull(); // slot freed
|
expect(row.planeId).toBeNull(); // slot freed
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const player = await getLocalPlayer(inst);
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
|
expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||||||
const inst = await setupLoaded('ab-mid.db');
|
const inst = await setupLoaded('ab-mid.db');
|
||||||
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
|
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps at exactly 60% at and past the deadline', async () => {
|
it('caps at exactly 60% at and past the deadline', async () => {
|
||||||
for (const [name, clock] of [
|
for (const [name, clock] of [
|
||||||
['ab-deadline.db', T0 + WINDOW_MS],
|
['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
|
||||||
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||||||
] as const) {
|
] as const) {
|
||||||
const inst = await setupLoaded(name);
|
const inst = await setupLoaded(name);
|
||||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is free for an already-expired package', async () => {
|
it('is free for an already-expired package', async () => {
|
||||||
const inst = await setupLoaded('ab-expired.db', 'expired');
|
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(0);
|
expect(penalty).toBe(0);
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
expect(row.status).toBe('abandoned');
|
expect(row.status).toBe('abandoned');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('floors cash at €0 — no debt', async () => {
|
it('floors cash at €0 — no debt', async () => {
|
||||||
const inst = await setupLoaded('ab-floor.db');
|
const inst = await setupLoaded('ab-floor.db');
|
||||||
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
|
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(150);
|
expect(penalty).toBe(150);
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
|
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects abandoning while the plane is in flight with 409', async () => {
|
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||||||
@@ -358,32 +514,32 @@ describe('abandonPackage', () => {
|
|||||||
.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(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||||
const inst = await freshDb(`ab-${status}.db`);
|
const inst = await freshDb(`ab-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('ab-unknown.db');
|
const inst = await freshDb('ab-unknown.db');
|
||||||
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buyPlane', () => {
|
describe('buyPlane', () => {
|
||||||
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
||||||
const inst = await freshDb('buy-ok.db');
|
const inst = await freshDb('buy-ok.db');
|
||||||
await inst.db.update(schema.account).set({ cash: 500_000 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||||
|
|
||||||
const before = (await inst.db.select().from(schema.planes)).length;
|
const before = (await inst.db.select().from(schema.planes)).length;
|
||||||
const { planeId } = await buyPlane(inst, 'atr72');
|
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const player = await getLocalPlayer(inst);
|
||||||
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||||
|
|
||||||
const planes = await inst.db.select().from(schema.planes);
|
const planes = await inst.db.select().from(schema.planes);
|
||||||
expect(planes.length).toBe(before + 1);
|
expect(planes.length).toBe(before + 1);
|
||||||
@@ -401,12 +557,44 @@ describe('buyPlane', () => {
|
|||||||
|
|
||||||
it('rejects an unknown catalog model with 400', async () => {
|
it('rejects an unknown catalog model with 400', async () => {
|
||||||
const inst = await freshDb('buy-unknown.db');
|
const inst = await freshDb('buy-unknown.db');
|
||||||
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
|
await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects the purchase when cash is below the price with 409', async () => {
|
it('rejects the purchase when cash is below the price with 409', async () => {
|
||||||
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
||||||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||||||
|
const inst = await freshDb('buy-rep.db');
|
||||||
|
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
|
||||||
|
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
||||||
|
|
||||||
|
const before = (await inst.db.select().from(schema.planes)).length;
|
||||||
|
await buyPlane(inst, 'atr72', 'local');
|
||||||
|
|
||||||
|
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||||||
|
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
|
||||||
|
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
|
||||||
|
const player = await getLocalPlayer(inst);
|
||||||
|
expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
|
||||||
|
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
|
||||||
|
const inst = await freshDb('buy-boundary.db');
|
||||||
|
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||||||
|
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
|
||||||
|
|
||||||
|
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||||
|
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||||
|
const player = await getLocalPlayer(inst);
|
||||||
|
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||||
|
|
||||||
|
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||||
|
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
||||||
|
await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,16 +603,16 @@ describe('sellPlane', () => {
|
|||||||
const inst = await freshDb('sell-ok.db');
|
const inst = await freshDb('sell-ok.db');
|
||||||
// A second plane so the starter is not "the last remaining one".
|
// A second plane so the starter is not "the last remaining one".
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { payout } = await sellPlane(inst, 'plane-extra');
|
const { payout } = await sellPlane(inst, 'plane-extra', 'local');
|
||||||
|
|
||||||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const player = await getLocalPlayer(inst);
|
||||||
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
|
expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
|
||||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||||||
expect(ids).not.toContain('plane-extra'); // row deleted
|
expect(ids).not.toContain('plane-extra'); // row deleted
|
||||||
expect(ids).toContain('plane-starter');
|
expect(ids).toContain('plane-starter');
|
||||||
@@ -433,187 +621,206 @@ describe('sellPlane', () => {
|
|||||||
it('rejects selling a flying plane with 409', async () => {
|
it('rejects selling a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-flying.db');
|
const inst = await freshDb('sell-flying.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||||
const inst = await freshDb('sell-cargo.db');
|
const inst = await freshDb('sell-cargo.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
|
||||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||||
|
const inst = await freshDb('sell-queue.db');
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
|
||||||
|
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects selling the last remaining plane with 409', async () => {
|
it('rejects selling the last remaining plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('sell-unknown.db');
|
const inst = await freshDb('sell-unknown.db');
|
||||||
await expectActionError(sellPlane(inst, 'ghost'), 404);
|
await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
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');
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
|
||||||
|
const player = await getLocalPlayer(inst);
|
||||||
|
expect(player.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 getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
expect(cost).toBe(0);
|
expect(cost).toBe(0);
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
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 getLocalPlayer(inst)).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', 'local'), 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', 'local'), 409, /only grounded planes can be repaired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||||
|
const inst = await freshDb('repair-blocks.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
|
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||||
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
|
||||||
|
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||||
|
const inst = await freshDb('repair-poor.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
||||||
|
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('refuel-unknown.db');
|
const inst = await freshDb('repair-unknown.db');
|
||||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- forFlight mode (v0.16.0) ---
|
describe('ownership (v1.2.0, ISS-109)', () => {
|
||||||
|
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
|
||||||
|
async function addPlayerB(inst: GameDb): Promise<void> {
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
|
}
|
||||||
|
|
||||||
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
|
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
|
||||||
const inst = await freshDb('refuel-forflight-ok.db');
|
const inst = await freshDb('own-403.db');
|
||||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
await addPlayerB(inst);
|
||||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
await insertOpenPackage(inst, 'p-own');
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
|
||||||
|
|
||||||
// Partial tank below the requirement so L > 0.
|
// Load as the owner first so abandon has a bound package to check…
|
||||||
const startFuel = Math.round(required / 2);
|
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
|
||||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
// Nothing was mutated by the rejected attempts.
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
|
expect(plane.status).toBe('available');
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
expect(JSON.parse(plane.queue)).toEqual([]);
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
|
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
|
||||||
const inst = await freshDb('refuel-ff-flyafter.db');
|
const inst = await freshDb('own-403-rev.db');
|
||||||
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
await addPlayerB(inst);
|
||||||
await loadPackage(inst, 'p-fa', 'plane-starter');
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
|
||||||
|
|
||||||
// Empty-ish tank: well below the leg requirement.
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
|
||||||
|
|
||||||
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane.status).toBe('flying');
|
|
||||||
expect(plane.destIcao).toBe('LEMD');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
it('enforces the last-plane guard per owner — not on the world total', async () => {
|
||||||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
const inst = await freshDb('own-lastplane.db');
|
||||||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
await addPlayerB(inst);
|
||||||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
// 'b' owns two planes; 'local' only the starter. World total: three.
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
for (const id of ['plane-b1', 'plane-b2']) {
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
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);
|
// Selling local's only plane is still forbidden even though the world has three planes…
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||||
|
// …while b can sell one of their two.
|
||||||
|
const cashBefore = (await getPlayer(inst, 'b')).cash;
|
||||||
|
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
|
||||||
|
|
||||||
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
|
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
|
||||||
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
|
||||||
|
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
|
||||||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
const inst = await freshDb('own-buy.db');
|
||||||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
await addPlayerB(inst);
|
||||||
});
|
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
|
||||||
|
|
||||||
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 { planeId } = await buyPlane(inst, 'atr72', 'b');
|
||||||
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(
|
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
|
||||||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
expect(bought.ownerId).toBe('b');
|
||||||
409,
|
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||||
/does not fit/i,
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+154
-100
@@ -1,26 +1,41 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { persist, type GameDb } from '@/lib/db/client';
|
import { persist, type GameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { weatherFor } from '@/lib/weather';
|
||||||
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
import { runAction } from '@/lib/sim/engine';
|
import { runAction } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import {
|
||||||
|
FUEL_DISCOUNT_PER_REP,
|
||||||
|
FUEL_PRICE_PER_L,
|
||||||
|
MAX_FUEL_DISCOUNT,
|
||||||
|
MAX_PLANE_DISCOUNT,
|
||||||
|
PLANE_DISCOUNT_PER_REP,
|
||||||
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
|
repRate,
|
||||||
|
SELL_FRACTION,
|
||||||
|
} from './catalog';
|
||||||
import { abandonPenalty } from './penalty';
|
import { abandonPenalty } from './penalty';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
|
|
||||||
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
||||||
// load a package onto a grounded plane at its origin, fly to any airport, deliver
|
// load a package onto a grounded plane at its origin, fly to any airport (dispatch
|
||||||
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
|
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
|
||||||
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
|
// grounded; plus buy/sell planes. Each function validates and mutates ONE throwaway-safe GameDb instance
|
||||||
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
||||||
// validate + mutate are atomic under the sim lock. Validation failures throw
|
// validate + mutate are atomic under the sim lock. Validation failures throw
|
||||||
// GameActionError carrying the HTTP status:
|
// GameActionError carrying the HTTP status:
|
||||||
// 404 — unknown entity id
|
// 404 — unknown entity id
|
||||||
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
||||||
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
|
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
|
||||||
|
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
|
||||||
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
||||||
|
|
||||||
export class GameActionError extends Error {
|
export class GameActionError extends Error {
|
||||||
@@ -40,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
|
|||||||
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
|
||||||
|
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
|
||||||
|
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
||||||
|
|
||||||
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
||||||
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
|
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
|
||||||
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
||||||
// delivered/abandoned).
|
// delivered/abandoned).
|
||||||
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
|
||||||
const [pkgRows, planeRows] = await Promise.all([
|
const [pkgRows, planeRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
||||||
@@ -58,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
}
|
}
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
||||||
}
|
}
|
||||||
@@ -78,8 +99,10 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
|
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.65.0: taking the job starts its slot's refill cooldown — the pool spawner holds this
|
||||||
|
// (origin, band) empty for the delay after now so the board visibly refreshes.
|
||||||
await inst.db.update(schema.packages)
|
await inst.db.update(schema.packages)
|
||||||
.set({ status: 'loaded', planeId })
|
.set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() })
|
||||||
.where(eq(schema.packages.id, packageId));
|
.where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
@@ -89,14 +112,23 @@ 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):
|
||||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
// 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, playerId: 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];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
||||||
}
|
}
|
||||||
|
if (plane.health <= 0) {
|
||||||
|
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
|
||||||
|
}
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
||||||
@@ -106,16 +138,39 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
const type = PLANE_TYPES[plane.model];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
|
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
||||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||||||
if (requiredL > plane.fuel) {
|
const w = weatherFor(plane.locationIcao);
|
||||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
|
||||||
|
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||||
|
|
||||||
|
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
||||||
|
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
|
||||||
|
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
|
||||||
|
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||||
|
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||||
|
const liters = Math.max(0, target - plane.fuel);
|
||||||
|
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
|
||||||
|
const player = await getPlayer(inst, playerId);
|
||||||
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
|
if (cost > 0 && player.cash < cost) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
||||||
|
}
|
||||||
|
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
|
||||||
|
if (requiredL > target) {
|
||||||
|
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} L`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAction() ticked first, so this is current game time.
|
// runAction() ticked first, so this is current game time.
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
|
||||||
|
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
|
||||||
|
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
|
||||||
|
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
|
||||||
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
|
||||||
|
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({
|
.set({
|
||||||
status: 'flying',
|
status: 'flying',
|
||||||
@@ -123,11 +178,13 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
departureSimTime: sim.clockMs,
|
departureSimTime: sim.clockMs,
|
||||||
lat: origin.lat,
|
lat: origin.lat,
|
||||||
lon: origin.lon,
|
lon: origin.lon,
|
||||||
|
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||||||
|
queue: JSON.stringify(queue),
|
||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside
|
if (cost > 0) {
|
||||||
// runAction(), so tick + unpause + dispatch are atomic under the sim lock.
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||||
await inst.db.update(schema.simState).set({ paused: false });
|
}
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,17 +195,27 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
||||||
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||||||
// in the airport dataset.
|
// in the airport dataset.
|
||||||
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: 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];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||||||
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
|
||||||
|
// leg already in the queue would only produce pointless repeated hops on landing.
|
||||||
|
if (plane.status === 'flying' && plane.destIcao === destIcao) {
|
||||||
|
throw new GameActionError(409, `already flying to ${destIcao}`);
|
||||||
|
}
|
||||||
|
|
||||||
const queue = parseIcaoQueue(plane.queue);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.includes(destIcao)) {
|
||||||
|
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
|
||||||
|
}
|
||||||
queue.push(destIcao);
|
queue.push(destIcao);
|
||||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
@@ -159,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
||||||
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||||
// known plane, integer index within [0, queue.length).
|
// known plane, integer index within [0, queue.length).
|
||||||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
|
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: 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];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
|
|
||||||
const queue = parseIcaoQueue(plane.queue);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
if (index < 0 || index >= queue.length) {
|
if (index < 0 || index >= queue.length) {
|
||||||
@@ -179,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
|
|||||||
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
||||||
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
||||||
// goes below €0. The slot is freed immediately.
|
// goes below €0. The slot is freed immediately.
|
||||||
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
|
export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
|
||||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
const pkg = pkgRows[0];
|
const pkg = pkgRows[0];
|
||||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||||
@@ -191,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
||||||
}
|
}
|
||||||
@@ -199,9 +268,9 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
const penalty = abandonPenalty(pkg, sim.clockMs);
|
const penalty = abandonPenalty(pkg, sim.clockMs);
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
|
||||||
if (!account) throw new Error('account row missing');
|
const player = await getPlayer(inst, playerId);
|
||||||
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
|
||||||
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { penalty };
|
return { penalty };
|
||||||
@@ -209,16 +278,19 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
|
|
||||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||||
|
|
||||||
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
||||||
// the home base (EDDF).
|
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
||||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
// at the home base (EDDF).
|
||||||
|
export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
|
||||||
const type = PLANE_TYPES[model];
|
const type = PLANE_TYPES[model];
|
||||||
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
// v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
|
||||||
if (!account) throw new Error('account row missing');
|
const player = await getPlayer(inst, playerId);
|
||||||
if (account.cash < type.price) {
|
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
|
||||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
|
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||||||
|
if (player.cash < cost) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
@@ -228,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
const planeId = randomUUID();
|
const planeId = randomUUID();
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: planeId,
|
id: planeId,
|
||||||
|
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
|
||||||
model,
|
model,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
fuel: type.maxFuel, // full tank on delivery
|
fuel: type.maxFuel, // full tank on delivery
|
||||||
@@ -237,8 +310,9 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
lat: home.lat,
|
lat: home.lat,
|
||||||
lon: home.lon,
|
lon: home.lon,
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
|
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||||
});
|
});
|
||||||
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { planeId };
|
return { planeId };
|
||||||
}
|
}
|
||||||
@@ -247,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
|
|
||||||
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
|
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
|
||||||
// remaining plane can never be sold — the company needs at least one aircraft.
|
// remaining plane can never be sold — the company needs at least one aircraft.
|
||||||
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
|
export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: 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`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
||||||
}
|
}
|
||||||
@@ -264,89 +339,61 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await inst.db.select().from(schema.planes);
|
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
|
||||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
if (parseIcaoQueue(plane.queue).length > 0) {
|
||||||
|
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
|
||||||
|
// players' planes exist in the world, and selling one of two of yours is fine.
|
||||||
|
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
|
||||||
|
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||||
|
|
||||||
const type = PLANE_TYPES[plane.model];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
||||||
|
|
||||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const player = await getPlayer(inst, playerId);
|
||||||
if (!account) throw new Error('account row missing');
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
|
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { payout };
|
return { payout };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||||||
|
|
||||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
|
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
|
||||||
// enough fuel for one loaded package's destination leg — required liters per the
|
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
|
||||||
// weighted burn model, independent of how much is already in the tank.
|
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
|
||||||
export interface RefuelOptions {
|
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||||||
mode?: 'full' | 'forFlight';
|
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||||||
packageId?: string;
|
// plane; 409 not grounded / insufficient funds.
|
||||||
}
|
export async function repairPlane(inst: GameDb, planeId: string, playerId: 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`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
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 sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
|
||||||
|
const player = await getPlayer(inst, playerId);
|
||||||
|
if (player.cash < REPAIR_COST) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = opts.mode ?? 'full';
|
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
|
||||||
let targetFuel: number; // tank level after the refuel
|
await inst.db.update(schema.planes)
|
||||||
if (mode === 'forFlight') {
|
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
|
.where(eq(schema.planes.id, planeId));
|
||||||
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
|
||||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
|
|
||||||
const pkg = pkgRows[0];
|
|
||||||
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
|
|
||||||
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
|
|
||||||
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
|
|
||||||
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = PLANE_TYPES[plane.model];
|
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
|
||||||
const origin = await airportByIcao(plane.locationIcao);
|
|
||||||
const dest = await airportByIcao(pkg.destIcao);
|
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
|
||||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
|
||||||
if (requiredL > plane.maxFuel) {
|
|
||||||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
|
||||||
}
|
|
||||||
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
|
|
||||||
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
|
|
||||||
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
|
|
||||||
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
|
|
||||||
targetFuel = Math.max(plane.fuel, requiredL);
|
|
||||||
} else {
|
|
||||||
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
|
||||||
targetFuel = plane.maxFuel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const liters = Math.max(0, targetFuel - plane.fuel);
|
|
||||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
|
||||||
if (!account) throw new Error('account row missing');
|
|
||||||
if (cost > 0 && account.cash < cost) {
|
|
||||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
|
|
||||||
if (cost > 0) {
|
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
|
||||||
}
|
|
||||||
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) ---
|
||||||
@@ -355,10 +402,17 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
|||||||
// game time — then respond with the FULL current state snapshot (same shape as
|
// game time — then respond with the FULL current state snapshot (same shape as
|
||||||
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
|
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
|
||||||
// its own status + {error}; anything unexpected is a 500.
|
// its own status + {error}; anything unexpected is a 500.
|
||||||
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
|
// v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
|
||||||
|
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
|
||||||
|
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
|
||||||
|
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
|
||||||
|
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
|
||||||
|
const auth = await requireAuth(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
try {
|
try {
|
||||||
await runAction(fn);
|
await runAction((inst) => fn(inst, auth.player.id));
|
||||||
return Response.json(await getStateSnapshot());
|
// v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`.
|
||||||
|
return Response.json(await getStateSnapshot(auth.player.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GameActionError) {
|
if (err instanceof GameActionError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status });
|
return Response.json({ error: err.message }, { status: err.status });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user