Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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"
|
||||||
|
|||||||
@@ -1,5 +1,95 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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 account table', async () => {
|
||||||
|
const res = await getRows('account');
|
||||||
|
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 ['cash', 'reputation']) expect(body.columns).toContain(col);
|
||||||
|
expect(body.rows).toHaveLength(1); // the single seeded account row
|
||||||
|
expect(typeof body.rows[0].__rowid).toBe('number');
|
||||||
|
expect(typeof body.rows[0].cash).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT updates a cell and the change survives a re-fetch', async () => {
|
||||||
|
const before = await (await getRows('account')).json();
|
||||||
|
const originalCash = (before as { rows: Array<Record<string, unknown>> }).rows[0].cash;
|
||||||
|
|
||||||
|
const res = await putRow('account', { rowid: 1, values: { cash: 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('account')).json();
|
||||||
|
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].cash).toBe(424_242);
|
||||||
|
|
||||||
|
// Restore the original cash so later tests see a sane world.
|
||||||
|
const restore = await putRow('account', { rowid: 1, values: { cash: originalCash } });
|
||||||
|
expect(restore.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT rejects unknown columns and empty values with 400', async () => {
|
||||||
|
const badCol = await putRow('account', { 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('account', { rowid: 1, values: {} });
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
|
||||||
|
const noRowid = await putRow('account', { values: { cash: 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 (account, 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 ['account', 'packages', '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,41 @@
|
|||||||
|
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 { GET } from './route';
|
||||||
|
|
||||||
|
let dir: 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/events', () => {
|
||||||
|
it('streams SSE with the current snapshot as the first event', async () => {
|
||||||
|
const res = await GET();
|
||||||
|
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');
|
||||||
|
} finally {
|
||||||
|
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
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.
|
||||||
|
getStateSnapshot()
|
||||||
|
.then((snapshot) => {
|
||||||
|
unsubscribe = subscribe((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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -57,7 +57,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');
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ignorePackage, performAction } from '@/lib/game/actions';
|
|
||||||
|
|
||||||
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
|
|
||||||
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
|
|
||||||
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
|
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await ctx.params;
|
|
||||||
return performAction((inst) => ignorePackage(inst, id));
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,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 +57,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 +82,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' });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,11 +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));
|
||||||
// special: null — the hook never rolls a special status (v0.37.0).
|
// nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs
|
||||||
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
|
// 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 });
|
||||||
|
|||||||
+139
@@ -588,3 +588,142 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,414 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
const SESSION_STORAGE_KEY = 'flight.session';
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
username: string;
|
||||||
|
admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowsData {
|
||||||
|
columns: string[];
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSession(): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
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) => ({ 'X-Flight-Session': s }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mount: a stored session decides between the grid and the login form.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const stored = readSession();
|
||||||
|
if (!stored) {
|
||||||
|
setChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/me', { headers: authHeaders(stored) })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok || cancelled) {
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
setSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as Me;
|
||||||
|
setSession(stored);
|
||||||
|
setMe(body);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
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 };
|
||||||
|
window.localStorage.setItem(SESSION_STORAGE_KEY, 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);
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+26
-8
@@ -11,12 +11,17 @@ 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';
|
||||||
|
|
||||||
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';
|
||||||
@@ -86,6 +91,9 @@ 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);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -96,7 +104,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));
|
||||||
@@ -216,7 +226,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,
|
||||||
@@ -358,7 +368,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',
|
||||||
},
|
},
|
||||||
@@ -405,7 +415,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
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({
|
||||||
@@ -459,7 +470,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -573,8 +584,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
<span data-testid="airport-count">
|
<span data-testid="airport-count">
|
||||||
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
||||||
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
|
Admin
|
||||||
|
</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 />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -221,16 +221,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
Load
|
Load
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`ignore-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
|
|
||||||
>
|
|
||||||
Ignore
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
@@ -238,8 +228,10 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||||
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||||
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||||
|
{/* 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>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
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
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -11,7 +11,7 @@
|
|||||||
"610368","CA-1292","large_airport","YEG","50.341652","-106.699219","2373","NA","CA","CA-AB","Edmonton","yes","","","","","","",""
|
"610368","CA-1292","large_airport","YEG","50.341652","-106.699219","2373","NA","CA","CA-AB","Edmonton","yes","","","","","","",""
|
||||||
"1763","CYHM","large_airport","John C. Munro Hamilton International Airport","43.171308","-79.929416","780","NA","CA","CA-ON","Hamilton","yes","CYHM","YHM","CYHM","","https://www.flyhi.ca/","https://en.wikipedia.org/wiki/Hamilton/John_C._Munro_International_Airport","Mount Hope Airport"
|
"1763","CYHM","large_airport","John C. Munro Hamilton International Airport","43.171308","-79.929416","780","NA","CA","CA-ON","Hamilton","yes","CYHM","YHM","CYHM","","https://www.flyhi.ca/","https://en.wikipedia.org/wiki/Hamilton/John_C._Munro_International_Airport","Mount Hope Airport"
|
||||||
"1770","CYHZ","large_airport","Halifax / Stanfield International Airport","44.8807983398","-63.5085983276","477","NA","CA","CA-NS","Halifax","yes","CYHZ","YHZ","CYHZ","","http://www.hiaa.ca/","https://en.wikipedia.org/wiki/Halifax_International_Airport","Robert L. Stanfield International Airport"
|
"1770","CYHZ","large_airport","Halifax / Stanfield International Airport","44.8807983398","-63.5085983276","477","NA","CA","CA-NS","Halifax","yes","CYHZ","YHZ","CYHZ","","http://www.hiaa.ca/","https://en.wikipedia.org/wiki/Halifax_International_Airport","Robert L. Stanfield International Airport"
|
||||||
"1808","CYLW","large_airport","Kelowna International Airport","49.9561","-119.377998","1421","NA","CA","CA-BC","Kelowna","yes","CYLW","YLW","CYLW","","http://www.kelowna.ca/CM/Page68.aspx","https://en.wikipedia.org/wiki/Kelowna_International_Airport","Kelowna, Okanagan"
|
"1808","CYLW","large_airport","Kelowna International Airport","49.9561","-119.377998","1421","NA","CA","CA-BC","Kelowna","yes","CYLW","YLW","CYLW","","https://ylw.kelowna.ca/","https://en.wikipedia.org/wiki/Kelowna_International_Airport","Kelowna, Okanagan"
|
||||||
"1840","CYOW","large_airport","Ottawa Macdonald-Cartier International Airport","45.322498","-75.669197","374","NA","CA","CA-ON","Ottawa","yes","CYOW","YOW","CYOW","YOW","https://yow.ca/","https://en.wikipedia.org/wiki/Ottawa_Macdonald%E2%80%93Cartier_International_Airport","Uplands, UUP, CUUP"
|
"1840","CYOW","large_airport","Ottawa Macdonald-Cartier International Airport","45.322498","-75.669197","374","NA","CA","CA-ON","Ottawa","yes","CYOW","YOW","CYOW","YOW","https://yow.ca/","https://en.wikipedia.org/wiki/Ottawa_Macdonald%E2%80%93Cartier_International_Airport","Uplands, UUP, CUUP"
|
||||||
"1864","CYQB","large_airport","Quebec Jean Lesage International Airport","46.7911","-71.393303","244","NA","CA","CA-QC","Quebec","yes","CYQB","YQB","CYQB","YQB","http://www.aeroportdequebec.com/","https://en.wikipedia.org/wiki/Qu%C3%A9bec/Jean_Lesage_International_Airport","Quebec City"
|
"1864","CYQB","large_airport","Quebec Jean Lesage International Airport","46.7911","-71.393303","244","NA","CA","CA-QC","Quebec","yes","CYQB","YQB","CYQB","YQB","http://www.aeroportdequebec.com/","https://en.wikipedia.org/wiki/Qu%C3%A9bec/Jean_Lesage_International_Airport","Quebec City"
|
||||||
"1867","CYQG","large_airport","Windsor International Airport","42.2756","-82.955597","622","NA","CA","CA-ON","Windsor","yes","CYQG","YQG","CYQG","","https://flyyqg.ca/","https://en.wikipedia.org/wiki/Windsor_Airport",""
|
"1867","CYQG","large_airport","Windsor International Airport","42.2756","-82.955597","622","NA","CA","CA-ON","Windsor","yes","CYQG","YQG","CYQG","","https://flyyqg.ca/","https://en.wikipedia.org/wiki/Windsor_Airport",""
|
||||||
@@ -1118,3 +1118,45 @@
|
|||||||
"27241","ZYQQ","large_airport","Qiqihar Sanjiazi Airport","47.229969","123.914179","477","AS","CN","CN-23","Qiqihar","yes","ZYQQ","NDG","ZYQQ","","","https://en.wikipedia.org/wiki/Qiqihar_Airport","齐齐哈尔, 齐齐哈尔三家子机场"
|
"27241","ZYQQ","large_airport","Qiqihar Sanjiazi Airport","47.229969","123.914179","477","AS","CN","CN-23","Qiqihar","yes","ZYQQ","NDG","ZYQQ","","","https://en.wikipedia.org/wiki/Qiqihar_Airport","齐齐哈尔, 齐齐哈尔三家子机场"
|
||||||
"27242","ZYTL","large_airport","Dalian Zhoushuizi International Airport","38.965719","121.538477","107","AS","CN","CN-21","Dalian (Ganjingzi)","yes","ZYTL","DLC","ZYTL","","https://dlairport.com","https://en.wikipedia.org/wiki/Dalian_Zhoushuizi_International_Airport","Dalian Air Base"
|
"27242","ZYTL","large_airport","Dalian Zhoushuizi International Airport","38.965719","121.538477","107","AS","CN","CN-21","Dalian (Ganjingzi)","yes","ZYTL","DLC","ZYTL","","https://dlairport.com","https://en.wikipedia.org/wiki/Dalian_Zhoushuizi_International_Airport","Dalian Air Base"
|
||||||
"27243","ZYTX","large_airport","Shenyang Taoxian International Airport","41.6398","123.483668","198","AS","CN","CN-21","Shenyang","yes","ZYTX","SHE","ZYTX","","","https://en.wikipedia.org/wiki/Shenyang_Taoxian_International_Airport","沈阳, 沈阳桃仙国际机场"
|
"27243","ZYTX","large_airport","Shenyang Taoxian International Airport","41.6398","123.483668","198","AS","CN","CN-21","Shenyang","yes","ZYTX","SHE","ZYTX","","","https://en.wikipedia.org/wiki/Shenyang_Taoxian_International_Airport","沈阳, 沈阳桃仙国际机场"
|
||||||
|
"1674","CYBK","medium_airport","Baker Lake Airport","64.29889678960001","-96.077796936","59","NA","CA","CA-NU","Baker Lake","yes","CYBK","YBK","CYBK","","","https://en.wikipedia.org/wiki/Baker_Lake_Airport",""
|
||||||
|
"1726","CYEV","medium_airport","Inuvik Mike Zubko Airport","68.30419921880001","-133.483001709","224","NA","CA","CA-NT","Inuvik","yes","CYEV","YEV","CYEV","","","https://en.wikipedia.org/wiki/Inuvik_(Mike_Zubko)_Airport",""
|
||||||
|
"1752","CYGW","medium_airport","Kuujjuarapik Airport","55.281898498535156","-77.76529693603516","34","NA","CA","CA-QC","Kuujjuarapik","yes","CYGW","YGW","CYGW","","","https://en.wikipedia.org/wiki/Kuujjuarapik_Airport",""
|
||||||
|
"1885","CYRB","medium_airport","Resolute Bay Airport","74.7169036865","-94.9693984985","215","NA","CA","CA-NU","Resolute Bay","yes","CYRB","YRB","CYRB","","","https://en.wikipedia.org/wiki/Resolute_Bay_Airport",""
|
||||||
|
"2877","FHAW","medium_airport","RAF Ascension Island","-7.970179","-14.392744","278","AF","SH","SH-AC","Cat Hill","yes","FHAW","ASI","FHAW","","https://www.raf.mod.uk/our-organisation/stations/ascension-island-base/","https://en.wikipedia.org/wiki/RAF_Ascension_Island","Wideawake Airfield, Ascension Island Auxiliary Field"
|
||||||
|
"4989","NTGJ","medium_airport","Totegegie Airport","-23.07990074157715","-134.88999938964844","7","OC","PF","PF-U-A","","yes","NTGJ","GMR","NTGJ","","","https://en.wikipedia.org/wiki/Totegegie_Airport",""
|
||||||
|
"5001","NTMD","medium_airport","Nuku Hiva Airport","-8.7956","-140.229004","220","OC","PF","PF-U-A","Nuku Hiva","yes","NTMD","NHV","NTMD","","","https://en.wikipedia.org/wiki/Nuku_Hiva_Airport",""
|
||||||
|
"5354","PADU","medium_airport","Tom Madsen (Dutch Harbor) Airport","53.89881","-166.544996","22","NA","US","US-AK","Unalaska","yes","PADU","DUT","PADU","DUT","","https://en.wikipedia.org/wiki/Unalaska_Airport","Dutch Harbor, Unalaska"
|
||||||
|
"5368","PAGM","medium_airport","Gambell Airport","63.767694","-171.733303","27","NA","US","US-AK","Gambell","yes","PAGM","GAM","PAGM","GAM","","https://en.wikipedia.org/wiki/Gambell_Airport",""
|
||||||
|
"5398","PAPG","medium_airport","Petersburg James A Johnson Airport","56.80170059","-132.9450073","111","NA","US","US-AK","Petersburg","yes","PAPG","PSG","PAPG","PSG","","https://en.wikipedia.org/wiki/Petersburg_James_A._Johnson_Airport",""
|
||||||
|
"5506","PWAK","medium_airport","Wake Island Airfield","19.282437","166.636637","14","OC","UM","UM-79","Wake Island","yes","PWAK","AWK","PWAK","AWK","","https://en.wikipedia.org/wiki/Wake_Island_Airfield",""
|
||||||
|
"6055","SEGS","medium_airport","Seymour Galapagos Ecological Airport","-0.453758","-90.2659","207","SA","EC","EC-W","Isla Baltra","yes","SEGS","GPS","SEGS","","https://ecogal.aero/en/","https://en.wikipedia.org/wiki/Seymour_Airport",""
|
||||||
|
"6450","UEST","medium_airport","Tiksi Airport","71.697700500488","128.90299987793","26","AS","RU","RU-SA","Tiksi","yes","UEST","IKS","UEST","","","https://en.wikipedia.org/wiki/Tiksi_Airport","УЕСТ, Аеропорт Тикси"
|
||||||
|
"6459","UHMP","medium_airport","Pevek Airport","69.783302","170.597","11","AS","RU","RU-CHU","Apapelgino","yes","UHMP","PWE","UHMP","","","https://en.wikipedia.org/wiki/Pevek_Airport","УХМП, Аэропорт Певек"
|
||||||
|
"26895","YAYE","medium_airport","Ayers Rock Connellan Airport","-25.185913","130.97703","1626","OC","AU","AU-NT","Yulara","yes","YAYE","AYQ","YAYE","","","https://en.wikipedia.org/wiki/Ayers_Rock_Airport","Ayers Rock, Uluru"
|
||||||
|
"44659","UHMF","medium_airport","Omsukchan Airport","62.457001","155.744995","1732","AS","RU","RU-MAG","Omsukchan","yes","UHMF","","UHMF","","","https://en.wikipedia.org/wiki/Omsukchan_Airport","Аэропорт Омсукчан"
|
||||||
|
"318444","FHSH","medium_airport","Saint Helena International Airport","-15.958898","-5.646066","1017","AF","SH","SH-SH","Jamestown","yes","FHSH","HLE","FHSH","","http://sthelenaairport.com/","https://en.m.wikipedia.org/wiki/Saint_Helena_Airport#",""
|
||||||
|
"1308","CNH2","small_airport","Natuashish Airport","55.913898","-61.184399","30","NA","CA","CA-NL","Natuashish","yes","","YNP","CNH2","CNH2","","https://en.wikipedia.org/wiki/Natuashish_Airport","NH2"
|
||||||
|
"31907","NCMH","small_airport","Manihiki Island Airport","-10.38091","-160.999424","11","OC","CK","CK-U-A","Manihiki Island","yes","NCMH","MHX","NCMH","","","https://en.wikipedia.org/wiki/Manihiki_Island_Airport","Humphrey Island, Te Fuinga o Niva"
|
||||||
|
"1806","CYLT","medium_airport","Alert Airport","82.517263","-62.283037","100","NA","CA","CA-NU","Alert","no","CYLT","YLT","CYLT","","","https://en.wikipedia.org/wiki/Alert_Airport","YLT"
|
||||||
|
"4582","HL56","medium_airport","Matan al-Sarra Air Base","21.6877","21.8309","1769","AF","LY","LY-KF","","no","","","HL56","HL56","","https://en.wikipedia.org/wiki/Maaten_al-Sarra_Air_Base",""
|
||||||
|
"5054","NZSP","medium_airport","Amundsen–Scott South Pole Station Airport","-90","-1","9300","AN","AQ","AQ-U-A","Amundsen-Scott South Pole Station","no","NZSP","","NZSP","","","https://en.wikipedia.org/wiki/Amundsen-Scott_South_Pole_Station",""
|
||||||
|
"5059","NZWD","medium_airport","Williams Field Skiway","-77.867401","167.057007","68","AN","AQ","AQ-U-A","McMurdo Station","no","","","NZWD","","","https://en.wikipedia.org/wiki/Williams_Field","Willies Field"
|
||||||
|
"5413","PASY","medium_airport","Eareckson Air Station","52.71229935","174.1139984","95","NA","US","US-AK","Shemya","no","PASY","SYA","PASY","SYA","","https://en.wikipedia.org/wiki/Eareckson_Air_Station",""
|
||||||
|
"5426","PCIS","medium_airport","Canton Island Airport","-2.7681200504300003","-171.710006714","9","OC","KI","KI-P","Abariringa","no","PCIS","CIS","PCIS","","","https://en.wikipedia.org/wiki/Canton_Island_Airport","Kanton Island"
|
||||||
|
"5494","PMDY","medium_airport","Henderson Field","28.2017","-177.380997","13","OC","UM","UM-71","Sand Island","no","PMDY","MDY","PMDY","MDY","","https://en.wikipedia.org/wiki/Henderson_Field_(Midway_Atoll)",""
|
||||||
|
"5535","RJAW","medium_airport","Ioto (Iwo Jima) Airbase","24.784153","141.32261","384","AS","JP","JP-13","Ogasawara","no","RJAW","IWO","RJAW","","","https://en.wikipedia.org/wiki/Central_Field_(Iwo_Jima)","Iwo Jima Airport, Central Field, Ioto, Iōtō"
|
||||||
|
"35041","UODN","medium_airport","Nagurskoye Airfield","80.803207","47.663586","59","AS","RU","RU-ARK","Alexandra Land","no","UODN","","UODN","","","https://en.wikipedia.org/wiki/Nagurskoye",""
|
||||||
|
"336953","AT03","medium_airport","Concordia Skyway","-75.103278","123.35825","10725","AN","AQ","AQ-U-A","Concordia Station","no","","","AT03","","","https://en.wikipedia.org/wiki/Concordia_Station",""
|
||||||
|
"603000","AQ-0016","medium_airport","Grandfather Skiway","-77.530242","-31.827393","","AN","AQ","AQ-U-A","Antartica","no","","","","","","",""
|
||||||
|
"11","NZ12","small_airport","Palmer Station Airport","-64.774582","-64.035907","149","AN","AQ","AQ-U-A","Palmer Station","no","","","NZ0B","NZ0B","","https://en.wikipedia.org/wiki/Palmer_Station",""
|
||||||
|
"1486","CRE2","small_airport","Edzo Airport","62.766701","-116.084","587","NA","CA","CA-NT","Rae-Edzo","no","","","CRE2","CRE2","","https://en.wikipedia.org/wiki/Rae/Edzo_Airport","RE2"
|
||||||
|
"5534","RJAM","small_airport","JMSDF Minami Torishima Air Base","24.2897","153.979004","22","AS","JP","JP-13","Ogasawara","no","RJAM","MUS","RJAM","","","https://en.wikipedia.org/wiki/Minami_Torishima_Airport","Marcus Island Airport"
|
||||||
|
"6021","SCFX","small_airport","Isla San Felix Airport","-26.2939","-80.096199","165","SA","CL","CL-AT","Isla San Felix","no","","","SCFX","","","https://en.wikipedia.org/wiki/Isla_San_Felix_Airport",""
|
||||||
|
"27793","YMNY","small_airport","Morney Airport","-25.358299255371094","141.43299865722656","104","OC","AU","AU-QLD","","no","YMNY","OXY","YMNY","","","",""
|
||||||
|
"32268","SCPZ","small_airport","Patriot Hills Airport","-80.318307","-81.308128","2900","AN","AQ","AQ-U-A","Patriot Hills Base Camp","no","SCPZ","","SCPZ","","","https://es.wikipedia.org/wiki/Aer%C3%B3dromo_Patriot_Hills",""
|
||||||
|
"39579","AQ-0001","small_airport","Novolazarevskaya Station","-70.8461","11.8472","390","AN","AQ","AQ-U-A","Queen Maud Land","no","","","AT17","","","https://en.wikipedia.org/wiki/Novolazarevskaya_Station",""
|
||||||
|
"42926","UIKE","small_airport","Yerbogachen Airport","61.2750015259","108.029998779","400","AS","RU","RU-IRK","Erbogachen","no","UIKE","ERG","UIKE","","","https://en.wikipedia.org/wiki/Erbogachen_Airport","Yerbogachyon Airport, Erbogachyon Airport, Erbogachen Airport, Аэропорт Ербогачен, Аэропорт Ербогачён"
|
||||||
|
"43672","ULDW","small_airport","Varandey Airport","68.848503","58.201401","23","AS","RU","RU-NEN","Varandey","no","ULDW","VRI","ULDW","","","https://en.wikipedia.org/wiki/Varandey_Airport","УЛДВ, Varandei Airport, Аэропорт Варандей"
|
||||||
|
"44051","BGDH","small_airport","Danmarkshavn Landing Strip","76.76667","-18.666668","","NA","GL","GL-NE","Danmarkshavn","no","","","BGDH","","","",""
|
||||||
|
"302312","YWKS","small_airport","Wilkins Runway","-66.690833","111.523611","2529","AN","AQ","AQ-U-A","Preston Heath","no","","","YWKS","","","https://en.wikipedia.org/wiki/Wilkins_Runway",""
|
||||||
|
"330063","AT25","small_airport","Showa Station Skiway","-69.006167","39.59","95","AN","AQ","AQ-U-A","Showa Station","no","","","AT25","","","https://en.wikipedia.org/wiki/Showa_Station_(Antarctica)","Syowa Station"
|
||||||
|
|||||||
|
@@ -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
|
||||||
|
|||||||
+4
-5
@@ -8,11 +8,10 @@ services:
|
|||||||
# 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_world.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:
|
||||||
|
|||||||
+19
-9
@@ -50,9 +50,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.
|
||||||
@@ -93,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is why the expiry
|
||||||
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
|
// test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health
|
||||||
|
// and no gate afterwards.
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, 'plane-starter', { health: 50 });
|
await setHealth(page, 'plane-starter', { health: 50 });
|
||||||
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
||||||
@@ -176,6 +182,10 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -334,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||||
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
|
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||||
// baseline below is captured AFTER deactivateWorld, not before it.
|
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||||
await activateWorld(page);
|
await activateWorld(page);
|
||||||
try {
|
try {
|
||||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// v0.71.0 — Admin panel e2e: top-bar button → modal with login form; 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 back to the login form.
|
||||||
|
|
||||||
|
const SESSION_KEY = 'flight.session'; // must match components/AdminPanel.tsx
|
||||||
|
|
||||||
|
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: login form, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const adminBtn = page.getByTestId('admin-btn');
|
||||||
|
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// --- Open the modal: no stored session → login form.
|
||||||
|
await adminBtn.click();
|
||||||
|
const dialog = page.locator('[role="dialog"]');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(page.getByTestId('admin-username')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('admin-password')).toBeVisible();
|
||||||
|
|
||||||
|
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
|
||||||
|
await page.getByTestId('admin-username').fill('kamilkrzywda');
|
||||||
|
await page.getByTestId('admin-password').fill('wrong-password-on-purpose');
|
||||||
|
await page.getByTestId('admin-login-submit').click();
|
||||||
|
await expect(page.getByTestId('admin-error')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// --- Authenticate via the test hook + localStorage, then reopen the panel.
|
||||||
|
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]);
|
||||||
|
await page.reload();
|
||||||
|
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
||||||
|
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 returns to the login form.
|
||||||
|
await page.getByTestId('admin-logout').click();
|
||||||
|
await expect(page.getByTestId('admin-username')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(tables).toBeHidden();
|
||||||
|
});
|
||||||
+9
-3
@@ -13,20 +13,26 @@ 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 seventeen 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}/ignore', // v0.29.0 (ISS-047 part B)
|
|
||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue',
|
'/api/planes/{planeId}/queue',
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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 () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/events`, { 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
|
||||||
|
});
|
||||||
+36
-29
@@ -48,9 +48,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 hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
@@ -84,33 +89,30 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
|||||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// 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) {
|
||||||
// Make sure the world rate is back at SCALE 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-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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +162,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 } }>;
|
||||||
@@ -319,8 +321,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);
|
||||||
@@ -359,14 +360,17 @@ test('hovering an open package highlights its endpoint airports', async ({ page
|
|||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
|
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
|
||||||
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
|
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
|
||||||
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
|
// 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('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
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
const pkg = await createPackage(page, 'NZAA', 'NFTF');
|
const pkg = await createPackage(page, HUB, 'NFTF');
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -374,11 +378,14 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c
|
|||||||
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 });
|
||||||
|
|
||||||
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
|
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
|
||||||
await fitToAirports(page, ['NZAA', 'NFTF']);
|
// ~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 row.hover();
|
||||||
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
|
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.
|
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
|
|||||||
@@ -42,9 +42,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 hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
|
|||||||
@@ -60,9 +60,14 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
@@ -115,7 +120,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;
|
||||||
@@ -149,7 +155,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
|
||||||
@@ -275,6 +297,11 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
|
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
// queryRenderedFeatures is viewport-scoped, and at the v0.70.0 default view ([0,0] z2) this pane
|
||||||
|
// 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
|
// 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
|
// 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
|
||||||
@@ -332,3 +359,245 @@ 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 (~3 min at 1000×) + 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, SCALE);
|
||||||
|
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, SCALE);
|
||||||
|
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')!)}`);
|
||||||
|
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)`);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
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, then chain back home so the other specs start from the canonical
|
||||||
|
// state. The package was delivered on arrival (generous deadline).
|
||||||
|
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 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, SCALE);
|
||||||
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+58
-17
@@ -8,9 +8,32 @@ const APP_URL = '/';
|
|||||||
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 () => {
|
||||||
@@ -27,6 +50,16 @@ test('page loads with the map and world airport count', async ({ page }) => {
|
|||||||
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 }) => {
|
||||||
@@ -56,18 +89,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 (~560 px at the
|
// the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
|
||||||
// default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays
|
await frameLfrs(page);
|
||||||
// isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and
|
const pt = await page.evaluate((c) => {
|
||||||
// LFBD Bordeaux, ~39 px away at the initial zoom [10,52] z4 (verified against data/airports_world.csv),
|
|
||||||
// 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');
|
||||||
@@ -138,12 +168,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);
|
||||||
});
|
});
|
||||||
@@ -189,6 +225,9 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
|
|||||||
await expect(page.getByTestId('airport-count')).toContainText(/world 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
|
||||||
@@ -258,12 +297,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');
|
||||||
|
|||||||
+25
-7
@@ -16,9 +16,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 hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
@@ -34,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, STARTER, { health: 50 });
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||||
@@ -146,7 +151,7 @@ test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, I
|
|||||||
// 409s on non-available planes — it must be grounded before activateWorld.
|
// 409s on non-available planes — it must be grounded before activateWorld.
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
||||||
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for full scale.
|
||||||
await activateWorld(page);
|
await activateWorld(page);
|
||||||
try {
|
try {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -192,3 +197,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');
|
||||||
|
});
|
||||||
|
|||||||
+11
-4
@@ -47,9 +47,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 hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
@@ -567,7 +572,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
|
|||||||
// 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.
|
// 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;
|
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||||||
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
||||||
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
|
// 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn;
|
||||||
|
// a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
||||||
|
expect(fuel).toBeGreaterThan(1.3 * required2);
|
||||||
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
|
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.
|
// Let leg 2 finish so the next test starts from a known airport.
|
||||||
|
|||||||
+33
-5
@@ -12,18 +12,40 @@ import { expect, test } from '@playwright/test';
|
|||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const 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; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||||||
@@ -156,6 +178,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 }) => {
|
||||||
@@ -236,6 +261,9 @@ test('airport popup Relocate queues a leg for a flying plane', 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);
|
||||||
|
|
||||||
const pt = await page.evaluate(({ lon, lat }) => {
|
const pt = await page.evaluate(({ lon, lat }) => {
|
||||||
const map = (window as any).__flightMap;
|
const map = (window as any).__flightMap;
|
||||||
const p = map.project([lon, lat]);
|
const p = map.project([lon, lat]);
|
||||||
|
|||||||
+29
-50
@@ -35,10 +35,12 @@ interface PlaneState {
|
|||||||
|
|
||||||
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;
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
@@ -70,9 +72,14 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
@@ -243,15 +250,19 @@ test('a priority package shows its special badge on the open-package row', async
|
|||||||
await expect(badge).toHaveText('Priority');
|
await expect(badge).toHaveText('Priority');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
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
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF}
|
||||||
|
|
||||||
const pkg = await createPackage(page, 'LEMD', HUB);
|
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 page.goto('/');
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 }) => {
|
||||||
@@ -267,19 +278,19 @@ 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).
|
||||||
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||||
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
|
// other planes), so the world clock is FROZEN (#135) — verify it actually is, then the preview must
|
||||||
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
|
// match the server exactly: a frozen clock means zero drift between any two snapshots.
|
||||||
const frozenClock = (await getState(page)).simState.clockMs;
|
const c0 = (await getState(page)).simState.clockMs;
|
||||||
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||||||
expect(
|
|
||||||
(await getState(page)).simState.clockMs,
|
|
||||||
'world should be frozen while the fleet is idle (ISS-117)',
|
|
||||||
).toBe(frozenClock);
|
|
||||||
const state = await getState(page);
|
const state = await getState(page);
|
||||||
|
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}`);
|
||||||
@@ -436,38 +447,6 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
|
|||||||
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/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
|
||||||
|
|
||||||
const nearest = await nearestToHub(page);
|
|
||||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
|
||||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
|
|
||||||
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
|
|
||||||
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
|
|
||||||
await ignoreBtn.click();
|
|
||||||
|
|
||||||
// Server truth: the package is 'ignored'…
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
|
|
||||||
timeout: 15_000,
|
|
||||||
message: 'waiting for the ignored status in /api/state',
|
|
||||||
})
|
|
||||||
.toBe('ignored');
|
|
||||||
|
|
||||||
// …and the row is gone from the open list (the section filters status==='open').
|
|
||||||
await expect(row).not.toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
|
|
||||||
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
|
||||||
expect(again.status()).toBe(409);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
|
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
|
||||||
|
|||||||
+113
-74
@@ -1,16 +1,19 @@
|
|||||||
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.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
|
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
||||||
// fleet, background-tick advance at a raised rate while something is active, package expiry via
|
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
||||||
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
|
// 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; // test rate for fast-forwarding legs
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
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 MARKET_TARGET = 10;
|
|
||||||
const STARTER = 'plane-starter';
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
@@ -23,9 +26,14 @@ interface StateBody {
|
|||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
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 hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
@@ -41,9 +49,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, STARTER, { health: 50 });
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||||
@@ -65,14 +73,64 @@ test.afterEach(async ({ page }) => {
|
|||||||
await setScale(page, WORLD);
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
|
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
|
||||||
const s = await getState(page);
|
const s = await getState(page);
|
||||||
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||||
const frozen = s.simState.clockMs;
|
|
||||||
for (let i = 0; i < 3; i++) {
|
const c0 = s.simState.clockMs;
|
||||||
await sleep(500);
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
|
||||||
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
|
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 }) => {
|
||||||
@@ -94,69 +152,50 @@ test('timeScale 1000 advances the clock by roughly real-time × 1000 between pol
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
const before = await getState(page);
|
||||||
await activateWorld(page);
|
const victim = before.packages.find((p) => p.status === 'open');
|
||||||
try {
|
expect(victim).toBeDefined(); // steady state always has open packages
|
||||||
const before = await getState(page);
|
|
||||||
const victim = before.packages.find((p) => p.status === 'open');
|
|
||||||
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
|
||||||
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
// scale or clock activation needed.
|
||||||
expect(hook.status()).toBe(200);
|
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
expect(hook.status()).toBe(200);
|
||||||
expect(moved.id).toBe(victim!.id);
|
const moved = (await hook.json()) as { id: string; status: string };
|
||||||
|
expect(moved.id).toBe(victim!.id);
|
||||||
|
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
|
||||||
|
|
||||||
await setScale(page, 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.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||||||
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
return !!after;
|
||||||
return !!expiredBody;
|
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
|
||||||
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
.toBe(true);
|
||||||
.toBe(true);
|
// …and the spawner refills the pool back up — never below its target.
|
||||||
|
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
|
||||||
const after = expiredBody!;
|
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
|
||||||
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
|
||||||
// Expired packages leave the available set…
|
|
||||||
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
|
||||||
// …and spawn tops the market back up to the target — never below it.
|
|
||||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
|
||||||
} finally {
|
|
||||||
await deactivateWorld(page);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
await activateWorld(page);
|
expect(created.status()).toBe(201);
|
||||||
try {
|
const c = (await created.json()) as { id: string; status: string };
|
||||||
const created = await page.request.post('/api/test/expire-package');
|
// The hook ages the job before its final tick, so it never even appears open in /api/state.
|
||||||
expect(created.status()).toBe(201);
|
expect(c.status).toBe('expired');
|
||||||
const c = (await created.json()) as { id: string; status: string };
|
|
||||||
expect(c.status).toBe('open');
|
|
||||||
|
|
||||||
await setScale(page, SCALE);
|
let after: StateBody | undefined;
|
||||||
|
await expect
|
||||||
let expiredBody: StateBody | undefined;
|
.poll(async () => {
|
||||||
await expect
|
const body = await getState(page);
|
||||||
.poll(async () => {
|
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||||||
const body = await getState(page);
|
return !!after;
|
||||||
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
|
||||||
return !!expiredBody;
|
.toBe(true);
|
||||||
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
|
||||||
} finally {
|
|
||||||
await deactivateWorld(page);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
|
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
|
||||||
|
|||||||
+7
-6
@@ -42,8 +42,8 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
|
// Self-heal to a fully grounded fleet first — a grounded fleet freezes the world clock (#135),
|
||||||
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
// so no tick advances it across the restart and the post-restart clock is exactly unchanged.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const res = await page.request.get('/api/state');
|
const res = await page.request.get('/api/state');
|
||||||
@@ -76,10 +76,11 @@ 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. With the fleet
|
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, the world
|
||||||
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
// clock is FROZEN (#135) — no tick advances it while the container is down or after restart, so
|
||||||
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
// the post-restart clock is EXACTLY unchanged; a re-seed would reset clockMs backwards instead.
|
||||||
|
expect(a.simState.clockMs - before.simState.clockMs).toBe(0);
|
||||||
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());
|
||||||
|
|||||||
+8
-3
@@ -18,9 +18,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 }) => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface PackageState {
|
|||||||
id: string;
|
id: string;
|
||||||
originIcao: string;
|
originIcao: string;
|
||||||
destIcao: string;
|
destIcao: string;
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +67,14 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
|||||||
+78
-1
@@ -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 { assertUniqueIcaos, filterWorldAirports, parseCsv, 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"';
|
||||||
@@ -132,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+27
-3
@@ -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,15 +53,38 @@ 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. The committed dataset is pre-curated to large_airport
|
// irrelevant to an airline-ops game. The committed dataset is pre-curated by
|
||||||
// only (scripts/build-world-airports.mjs); this check stays as a cheap guard against an
|
// scripts/build-world-airports.mjs: every large_airport plus the small/medium/seaplane rows
|
||||||
// uncurated dump being swapped in.
|
// 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 filterWorldAirports(rows: CsvRow[]): CsvRow[] {
|
export function filterWorldAirports(rows: CsvRow[]): CsvRow[] {
|
||||||
return rows.filter((r) => 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 {
|
||||||
name: string;
|
name: string;
|
||||||
icao: string;
|
icao: string;
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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;
|
||||||
|
const inst = await getGameDb();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -222,6 +222,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.
|
||||||
|
expect((await inst.db.select().from(schema.account))[0]?.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();
|
||||||
|
|||||||
+57
-5
@@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
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 (
|
CREATE TABLE IF NOT EXISTS account (
|
||||||
cash REAL NOT NULL,
|
cash REAL NOT NULL,
|
||||||
@@ -60,8 +62,46 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
|||||||
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
|
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.
|
||||||
@@ -98,6 +138,15 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('planes').has('repair_until_sim_time')) {
|
if (!cols('planes').has('repair_until_sim_time')) {
|
||||||
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
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
|
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||||
@@ -150,6 +199,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.
|
||||||
@@ -211,12 +261,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> {
|
||||||
|
|||||||
+29
-2
@@ -53,10 +53,17 @@ export const packages = sqliteTable('packages', {
|
|||||||
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.
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
|
|
||||||
planeId: text('plane_id'),
|
planeId: text('plane_id'),
|
||||||
createdSimTime: integer('created_sim_time').notNull(),
|
createdSimTime: integer('created_sim_time').notNull(),
|
||||||
|
// 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', {
|
export const account = sqliteTable('account', {
|
||||||
@@ -77,7 +84,27 @@ export const simState = sqliteTable('sim_state', {
|
|||||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
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 AccountRow = typeof account.$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;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
weightKg: 600,
|
weightKg: 600,
|
||||||
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
|
expiresRealMs: null, // v0.65.0 — wall-clock expiry; not exercised here
|
||||||
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||||
deadlineSimTime: 0,
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
@@ -63,7 +64,7 @@ describe('diffSnapshots', () => {
|
|||||||
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([]);
|
||||||
});
|
});
|
||||||
@@ -112,14 +113,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') {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
cancelQueuedLeg,
|
cancelQueuedLeg,
|
||||||
flyPlane,
|
flyPlane,
|
||||||
GameActionError,
|
GameActionError,
|
||||||
ignorePackage,
|
|
||||||
loadPackage,
|
loadPackage,
|
||||||
queueLeg,
|
queueLeg,
|
||||||
repairPlane,
|
repairPlane,
|
||||||
@@ -530,36 +529,6 @@ describe('abandonPackage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ignorePackage', () => {
|
|
||||||
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
|
|
||||||
const inst = await freshDb('ig-open.db');
|
|
||||||
await insertOpenPackage(inst, 'p-ig');
|
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
|
||||||
|
|
||||||
await ignorePackage(inst, 'p-ig');
|
|
||||||
|
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
|
|
||||||
expect(row.status).toBe('ignored');
|
|
||||||
expect(row.planeId).toBeNull(); // was never bound
|
|
||||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
|
|
||||||
'rejects a %s package with 409 (must be open)',
|
|
||||||
async (status) => {
|
|
||||||
const inst = await freshDb(`ig-${status}.db`);
|
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
|
||||||
|
|
||||||
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
|
||||||
const inst = await freshDb('ig-unknown.db');
|
|
||||||
await expectActionError(ignorePackage(inst, 'nope'), 404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buyPlane', () => {
|
describe('buyPlane', () => {
|
||||||
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
||||||
const inst = await freshDb('buy-ok.db');
|
const inst = await freshDb('buy-ok.db');
|
||||||
|
|||||||
+3
-18
@@ -90,8 +90,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);
|
||||||
}
|
}
|
||||||
@@ -261,23 +263,6 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
return { penalty };
|
return { penalty };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
|
|
||||||
|
|
||||||
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
|
|
||||||
// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with
|
|
||||||
// a new random package on the next tick. 404 unknown id; 409 unless open.
|
|
||||||
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
|
|
||||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
|
||||||
const pkg = pkgRows[0];
|
|
||||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
|
||||||
if (pkg.status !== 'open') {
|
|
||||||
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
|
|
||||||
await persist(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||||
|
|
||||||
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.54.0 (ISS-119): fastest cruise speed in the catalog — a generated job must be finishable by
|
||||||
|
// the best available aircraft, so seed's expiry floor references this, not the starter's 510 km/h
|
||||||
|
// (that would wreck long-job economics). Data-driven: adding a faster plane tightens the floor.
|
||||||
|
export const FASTEST_CATALOG_SPEED_KMH = Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh));
|
||||||
|
|
||||||
// The plane the player starts with.
|
// The plane the player starts with.
|
||||||
export const STARTER_MODEL = 'atr72';
|
export const STARTER_MODEL = 'atr72';
|
||||||
|
|
||||||
|
|||||||
+119
-25
@@ -1,19 +1,23 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
import { travelDurationMs } from '@/lib/sim/movement';
|
||||||
|
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
import { SPECIALS } from './specials';
|
import { SPECIALS } from './specials';
|
||||||
import {
|
import {
|
||||||
BASE_REWARD,
|
BASE_REWARD,
|
||||||
buildSeed,
|
buildSeed,
|
||||||
|
EXPIRY_BUFFER_MS,
|
||||||
JOB_BONUS_PER_REP,
|
JOB_BONUS_PER_REP,
|
||||||
MAX_JOB_BONUS,
|
MAX_JOB_BONUS,
|
||||||
PACKAGE_COUNT_MAX,
|
|
||||||
PACKAGE_COUNT_MIN,
|
|
||||||
generatePackage,
|
generatePackage,
|
||||||
|
generatePoolPackage,
|
||||||
HUB_ICAO,
|
HUB_ICAO,
|
||||||
MAX_JOB_LEG_KM,
|
MAX_JOB_LEG_KM,
|
||||||
pickDestWithinCap,
|
pickDestWithinCap,
|
||||||
|
POOL_BANDS,
|
||||||
|
poolBandOf,
|
||||||
|
POOL_TARGET_PER_AIRPORT,
|
||||||
mulberry32,
|
mulberry32,
|
||||||
REWARD_PER_KG,
|
REWARD_PER_KG,
|
||||||
REWARD_PER_KM,
|
REWARD_PER_KM,
|
||||||
@@ -63,20 +67,26 @@ describe('buildSeed', () => {
|
|||||||
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
|
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds 8–12 open packages between distinct fixture airports', () => {
|
it('seeds a full pool of POOL_TARGET_PER_AIRPORT open packages at the hub (v0.65.0)', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
|
expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
|
|
||||||
|
|
||||||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||||||
|
const byBand = [0, 0, 0, 0];
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
expect(c.originIcao).toBe(HUB_ICAO); // the starter's home hub — its only pool airport
|
||||||
|
const band = POOL_BANDS[poolBandOf(c.weightKg)];
|
||||||
|
byBand[poolBandOf(c.weightKg)]++;
|
||||||
|
expect(c.weightKg).toBeGreaterThanOrEqual(band.minKg); // weight stays in its band…
|
||||||
|
expect(c.weightKg).toBeLessThanOrEqual(band.maxKg); // …on both ends
|
||||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||||
expect(c.originIcao).not.toBe(c.destIcao);
|
expect(c.destIcao).not.toBe(HUB_ICAO);
|
||||||
expect(c.status).toBe('open');
|
expect(c.status).toBe('open');
|
||||||
expect(c.planeId).toBeNull();
|
expect(c.planeId).toBeNull();
|
||||||
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
|
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
|
||||||
|
expect(c.createdRealMs).toBe(NOW); // …but a fresh real-time lifetime from the seed moment
|
||||||
}
|
}
|
||||||
|
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
|
||||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,15 +101,16 @@ describe('buildSeed', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
|
it('gives every package a positive integer weight, and the board always offers jobs beyond the starter', () => {
|
||||||
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
|
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
|
||||||
for (const seed of [seedWith(42), seedWith(7)]) {
|
for (const seed of [seedWith(42), seedWith(7)]) {
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
expect(Number.isInteger(c.weightKg)).toBe(true);
|
expect(Number.isInteger(c.weightKg)).toBe(true);
|
||||||
expect(c.weightKg).toBeGreaterThan(0);
|
expect(c.weightKg).toBeGreaterThan(0);
|
||||||
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
|
|
||||||
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
|
|
||||||
}
|
}
|
||||||
|
// v0.65.0: the heavy bands (18–80 t) deliberately exceed the starter's payload — bigger jobs are
|
||||||
|
// what you buy better planes for, so the board must always show work it can't yet do alone.
|
||||||
|
expect(seed.packages.some((c) => c.weightKg > maxPayload)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,8 +180,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
||||||
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
|
||||||
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
|
|
||||||
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
|
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
|
||||||
@@ -184,13 +195,15 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
|
it('buildSeed packages are exactly the shared pool builder output (spawn matches seed)', () => {
|
||||||
const rngA = mulberry32(42);
|
const rngA = mulberry32(42);
|
||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
|
||||||
const expected: string[] = [];
|
const expected: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
let n = 1;
|
||||||
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
|
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
|
||||||
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
|
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
|
||||||
|
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
|
||||||
|
expected.push(JSON.stringify(generatePoolPackage({ airports: AIRPORTS, originIcao: HUB_ICAO, bandIdx, rng: rngA, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: NOW })));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
||||||
@@ -232,8 +245,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||||||
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
|
||||||
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,8 +259,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
|
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
|
||||||
// Same seed as a historical unforced call → same package, proving forced
|
// Same seed as a historical unforced call → same package, proving forced
|
||||||
// endpoints consume no extra RNG draws when absent.
|
// endpoints consume no extra RNG draws when absent.
|
||||||
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
|
||||||
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -264,7 +277,7 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
|
||||||
|
|
||||||
// Deterministic for a fixed RNG…
|
// Deterministic for a fixed RNG…
|
||||||
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234, createdRealMs: c.createdRealMs });
|
||||||
expect(again).toEqual(c);
|
expect(again).toEqual(c);
|
||||||
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
|
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
|
||||||
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
@@ -321,8 +334,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to the starting reputation when omitted — byte-identical output', () => {
|
it('defaults to the starting reputation when omitted — byte-identical output', () => {
|
||||||
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
|
||||||
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
|
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION, createdRealMs: NOW });
|
||||||
expect(a).toEqual(b); // no bonus at the starting value
|
expect(a).toEqual(b); // no bonus at the starting value
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,3 +450,84 @@ describe('distance cap (v0.44.0, ISS-079)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('expiry floor (v0.54.0, ISS-119)', () => {
|
||||||
|
// Pre-v0.54.0 deadline for a forced-endpoint + forced-special package: replays the fixed draw order
|
||||||
|
// (cargoType, cargoCount, weight jitter, window — forced inputs consume no draws) so short-leg
|
||||||
|
// packages can be pinned byte-identical to the old behavior.
|
||||||
|
const oldDeadlineFor = (seed: number, scale: number): number => {
|
||||||
|
const rng = mulberry32(seed);
|
||||||
|
rng(); // cargoType
|
||||||
|
rng(); // cargoCount
|
||||||
|
rng(); // weight jitter
|
||||||
|
const w = rng(); // deadline window
|
||||||
|
return Math.round(NOW + (6 + w * (72 - 6)) * 3_600_000 * scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('pins the constants: fastest catalog speed (not the starter) and a flat 1 h buffer', () => {
|
||||||
|
expect(FASTEST_CATALOG_SPEED_KMH).toBe(Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh)));
|
||||||
|
expect(FASTEST_CATALOG_SPEED_KMH).toBeGreaterThan(PLANE_TYPES[STARTER_MODEL].speedKmh);
|
||||||
|
expect(EXPIRY_BUFFER_MS).toBe(3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every generated package on the real dataset satisfies deadline ≥ now + travel time at max speed + buffer', async () => {
|
||||||
|
const { loadAirportsGeoJson } = await import('@/lib/airports');
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
|
||||||
|
const o = airports.find((a) => a.icao === c.originIcao)!;
|
||||||
|
const d = airports.find((a) => a.icao === c.destIcao)!;
|
||||||
|
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||||
|
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('long legs near MAX_JOB_LEG_KM are clamped up to the floor for every special (real dataset)', async () => {
|
||||||
|
const { loadAirportsGeoJson } = await import('@/lib/airports');
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
|
||||||
|
// Farthest pair within the cap — a leg as long as any generated job can be.
|
||||||
|
let bestI = -1;
|
||||||
|
let bestJ = -1;
|
||||||
|
let bestKm = 0;
|
||||||
|
for (let i = 0; i < airports.length; i++) {
|
||||||
|
for (let j = i + 1; j < airports.length; j++) {
|
||||||
|
const km = haversineKm(airports[i].lat, airports[i].lon, airports[j].lat, airports[j].lon);
|
||||||
|
if (km <= MAX_JOB_LEG_KM && km > bestKm) {
|
||||||
|
bestI = i;
|
||||||
|
bestJ = j;
|
||||||
|
bestKm = km;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
|
||||||
|
|
||||||
|
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||||
|
for (const special of ['priority', null] as const) {
|
||||||
|
let clamped = 0;
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const c = generatePackage({ id: `c-long-${special ?? 'plain'}-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW, originIcao: airports[bestI].icao, destIcao: airports[bestJ].icao, special });
|
||||||
|
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); // the invariant
|
||||||
|
if (c.deadlineSimTime === Math.round(NOW + floorMs)) clamped++; // landed exactly on the floor → the clamp fired
|
||||||
|
}
|
||||||
|
// Deterministic for these fixed seeds — proves the clamp actually engages, not just vacuously holds.
|
||||||
|
expect(clamped).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('short legs are byte-identical to the pre-v0.54.0 draw (no economy regression)', () => {
|
||||||
|
// EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h — below even the priority minimum window (3 h), so the clamp never fires here.
|
||||||
|
for (const seed of [1, 2, 3, 4, 5]) {
|
||||||
|
const plain = generatePackage({ id: 'c-short', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null });
|
||||||
|
expect(plain.deadlineSimTime).toBe(oldDeadlineFor(seed, 1));
|
||||||
|
const prio = generatePackage({ id: 'c-short-p', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: 'priority' });
|
||||||
|
expect(prio.deadlineSimTime).toBe(oldDeadlineFor(seed, SPECIALS.priority.deadlineScale));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forced deadline offsets still skip the floor (e2e expiry hook)', () => {
|
||||||
|
const c = generatePackage({ id: 'c-floor-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null, deadlineOffsetMs: 10_000 });
|
||||||
|
expect(c.deadlineSimTime).toBe(NOW + 10_000); // the hook's near-term expiry is untouched by the floor
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+113
-15
@@ -1,6 +1,7 @@
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
|
import { travelDurationMs } from '@/lib/sim/movement';
|
||||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
import { pickSpecial, SPECIALS } from './specials';
|
import { pickSpecial, SPECIALS } from './specials';
|
||||||
|
|
||||||
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
||||||
@@ -62,6 +63,9 @@ export interface SeedPackage {
|
|||||||
status: 'open';
|
status: 'open';
|
||||||
planeId: null;
|
planeId: null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
|
// Real-time (Date.now()) creation stamp (v0.65.0) — open jobs expire on the wall clock, not the sim
|
||||||
|
// clock, so this is what their lifetime is measured from.
|
||||||
|
createdRealMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedData {
|
export interface SeedData {
|
||||||
@@ -85,12 +89,44 @@ export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
|
|||||||
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
||||||
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
|
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
|
||||||
|
|
||||||
export const PACKAGE_COUNT_MIN = 8;
|
// v0.65.0: per-airport job pools replace the global market target (v0.7.0). Each airport where a
|
||||||
export const PACKAGE_COUNT_MAX = 12; // inclusive
|
// plane can take work keeps this fixed composition of open packages — heavier bands are rarer, so
|
||||||
|
// big jobs feel like opportunities instead of background noise.
|
||||||
|
export const POOL_BANDS = [
|
||||||
|
{ minKg: 500, maxKg: 3_999, count: 2 }, // <4 t
|
||||||
|
{ minKg: 4_000, maxKg: 17_999, count: 6 }, // 4–18 t
|
||||||
|
{ minKg: 18_000, maxKg: 48_000, count: 1 }, // 18–48 t
|
||||||
|
{ minKg: 48_001, maxKg: 80_000, count: 1 }, // >48 t
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Market target (v0.7.0): the sim engine tops open packages back up to this
|
// Which pool band a package's weight falls into (v0.65.0) — the spawner counts open packages per
|
||||||
// count after expiries, so the job board never runs dry.
|
// band and holds taken slots per band, so this must agree with POOL_BANDS' boundaries exactly.
|
||||||
export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
export function poolBandOf(weightKg: number): number {
|
||||||
|
if (weightKg < 4_000) return 0;
|
||||||
|
if (weightKg < 18_000) return 1;
|
||||||
|
if (weightKg <= 48_000) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total open packages per pool airport — the seed builds exactly this at the starter's home hub.
|
||||||
|
export const POOL_TARGET_PER_AIRPORT = POOL_BANDS.reduce((sum, b) => sum + b.count, 0);
|
||||||
|
|
||||||
|
// v0.65.0: open jobs live on the WALL clock, not the sim clock — they expire JOB_LIFETIME_MS after
|
||||||
|
// creation regardless of time scale or fleet state (checked every tick), so a paused world still
|
||||||
|
// refreshes its board. Test hooks may shorten these via env when FLIGHT_TEST_HOOKS=1 (CI sets the
|
||||||
|
// refill delays to seconds; the lifetime stays at 1 h everywhere).
|
||||||
|
function envMs(name: string, fallback: number): number {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') return fallback;
|
||||||
|
const v = Number(process.env[name]);
|
||||||
|
return Number.isFinite(v) && v > 0 ? Math.round(v) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
|
||||||
|
// Refill cooldown after a job is TAKEN (loaded): the slot stays empty this long so the
|
||||||
|
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
|
||||||
|
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
|
||||||
|
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
|
||||||
|
export const SMALL_REFILL_DELAY_MS = envMs('FLIGHT_JOB_SMALL_REFILL_MS', 60_000); // 1 min wall clock
|
||||||
|
|
||||||
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
||||||
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
||||||
@@ -123,6 +159,10 @@ const LEG_CAP_MAX_ATTEMPTS = 25;
|
|||||||
const DEADLINE_MIN_HOURS = 6;
|
const DEADLINE_MIN_HOURS = 6;
|
||||||
const DEADLINE_MAX_HOURS = 72;
|
const DEADLINE_MAX_HOURS = 72;
|
||||||
|
|
||||||
|
// v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at
|
||||||
|
// least this much after the fastest plane lands, so dispatch + landing are never instant.
|
||||||
|
export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h
|
||||||
|
|
||||||
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
||||||
|
|
||||||
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
|
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
|
||||||
@@ -157,6 +197,11 @@ export function rewardFor(km: number, weightKg: number): number {
|
|||||||
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
||||||
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
||||||
// starting value so seed-time behavior is byte-identical.
|
// starting value so seed-time behavior is byte-identical.
|
||||||
|
// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at
|
||||||
|
// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is
|
||||||
|
// finishable by the fastest catalog plane; short legs are byte-identical to before.
|
||||||
|
// v0.65.0: createdRealMs optionally FORCES the wall-clock creation stamp (default Date.now()) — a pure
|
||||||
|
// input consuming no RNG draws; the seed passes it explicitly so seeded worlds stay deterministic.
|
||||||
export function generatePackage(input: {
|
export function generatePackage(input: {
|
||||||
id: string;
|
id: string;
|
||||||
airports: SeedAirport[];
|
airports: SeedAirport[];
|
||||||
@@ -168,6 +213,7 @@ export function generatePackage(input: {
|
|||||||
weightKg?: number;
|
weightKg?: number;
|
||||||
special?: string | null;
|
special?: string | null;
|
||||||
reputation?: number;
|
reputation?: number;
|
||||||
|
createdRealMs?: number;
|
||||||
}): SeedPackage {
|
}): SeedPackage {
|
||||||
const { airports, rng, nowMs } = input;
|
const { airports, rng, nowMs } = input;
|
||||||
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
||||||
@@ -223,6 +269,9 @@ export function generatePackage(input: {
|
|||||||
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
||||||
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
|
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
|
||||||
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
||||||
|
// v0.65.0: a FORCED weight overrides the drawn count so display/payload math agree with weightKg —
|
||||||
|
// pool packages carry band weights far beyond the starter's payload, where the drawn count would lie.
|
||||||
|
const finalCargoCount = input.weightKg !== undefined ? Math.max(1, Math.round(input.weightKg / UNIT_WEIGHT_KG[cargoType])) : cargoCount;
|
||||||
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
|
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
|
||||||
// below AFTER the appended special draw so the fixed order above holds.
|
// below AFTER the appended special draw so the fixed order above holds.
|
||||||
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
||||||
@@ -230,8 +279,15 @@ export function generatePackage(input: {
|
|||||||
// forced value (including explicit null) skips it, same pattern as the other overrides.
|
// forced value (including explicit null) skips it, same pattern as the other overrides.
|
||||||
const special = input.special !== undefined ? input.special : pickSpecial(rng);
|
const special = input.special !== undefined ? input.special : pickSpecial(rng);
|
||||||
const mods = special !== null ? SPECIALS[special] : undefined;
|
const mods = special !== null ? SPECIALS[special] : undefined;
|
||||||
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
|
// v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane:
|
||||||
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
|
// deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a
|
||||||
|
// single uniform value, so clamping it up AFTER special scaling 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 offsets skip the floor, same as they skip the special scaling.
|
||||||
|
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||||
|
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
|
||||||
|
|
||||||
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||||||
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
||||||
@@ -242,16 +298,51 @@ export function generatePackage(input: {
|
|||||||
destIcao: dest.icao,
|
destIcao: dest.icao,
|
||||||
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
||||||
cargoType,
|
cargoType,
|
||||||
cargoCount,
|
cargoCount: finalCargoCount,
|
||||||
weightKg,
|
weightKg,
|
||||||
special,
|
special,
|
||||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: nowMs,
|
createdSimTime: nowMs,
|
||||||
|
createdRealMs: input.createdRealMs ?? Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.65.0: one pool package — a forced origin (the pool airport), a within-cap random destination
|
||||||
|
// (same cap as generated legs, so every pool job is flyable by some catalog plane), and a weight drawn
|
||||||
|
// uniformly from the band's range. Shared by buildSeed and the runtime spawner so seeded and spawned
|
||||||
|
// packages are indistinguishable. RNG draws per package: dest, weight, then generatePackage's own order.
|
||||||
|
export function generatePoolPackage(input: {
|
||||||
|
airports: SeedAirport[];
|
||||||
|
originIcao: string;
|
||||||
|
bandIdx: number;
|
||||||
|
rng: Rng;
|
||||||
|
clockMs: number; // game time — anchors createdSimTime/deadlineSimTime
|
||||||
|
id: string;
|
||||||
|
createdRealMs?: number; // wall-clock stamp (default Date.now())
|
||||||
|
reputation?: number;
|
||||||
|
}): SeedPackage {
|
||||||
|
const { airports, originIcao, bandIdx, rng } = input;
|
||||||
|
const oi = airports.findIndex((a) => a.icao === originIcao);
|
||||||
|
if (oi < 0) throw new Error(`unknown pool airport ${originIcao}`);
|
||||||
|
const di = pickDestWithinCap(airports, oi, rng);
|
||||||
|
if (di < 0) throw new Error(`no within-cap destination from ${originIcao}`);
|
||||||
|
const band = POOL_BANDS[bandIdx];
|
||||||
|
const weightKg = Math.round(band.minKg + rng() * (band.maxKg - band.minKg));
|
||||||
|
return generatePackage({
|
||||||
|
id: input.id,
|
||||||
|
airports,
|
||||||
|
rng,
|
||||||
|
nowMs: input.clockMs,
|
||||||
|
originIcao,
|
||||||
|
destIcao: airports[di].icao,
|
||||||
|
weightKg,
|
||||||
|
createdRealMs: input.createdRealMs ?? Date.now(),
|
||||||
|
reputation: input.reputation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
|
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
|
||||||
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
|
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
|
||||||
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
|
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
|
||||||
@@ -294,13 +385,20 @@ export function buildSeed(input: {
|
|||||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||||
};
|
};
|
||||||
|
|
||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
|
||||||
// Stable index-based ids for the seed set; runtime-generated entities must use
|
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
||||||
// random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock
|
// Stable index-based ids for the seed set; runtime-generated entities must use random UUIDs to avoid
|
||||||
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate.
|
// colliding with these. Deadlines are drawn relative to the GAME clock origin (ISS-117), not the real
|
||||||
|
// wall time, so they stay meaningful at the fixed world rate; createdRealMs is the REAL seed moment
|
||||||
|
// (input.nowMs) so seeded jobs get a fresh full wall-clock lifetime.
|
||||||
const packages: SeedPackage[] = [];
|
const packages: SeedPackage[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
let n = 1;
|
||||||
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
|
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
|
||||||
|
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
|
||||||
|
packages.push(
|
||||||
|
generatePoolPackage({ airports, originIcao: hub.icao, bandIdx, rng, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: nowMs }),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
||||||
|
|
||||||
|
const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
||||||
|
id,
|
||||||
|
originIcao: 'LEMD',
|
||||||
|
originName: 'Madrid–Barajas',
|
||||||
|
destIcao: 'EHAM',
|
||||||
|
destName: 'Amsterdam Schiphol',
|
||||||
|
reward: 1000,
|
||||||
|
cargoType: 'mail',
|
||||||
|
cargoCount: 3,
|
||||||
|
weightKg: 900,
|
||||||
|
special: null,
|
||||||
|
expiresRealMs: status === 'open' ? 2_000_000 : null, // v0.65.0 — open jobs carry their wall-clock expiry
|
||||||
|
distanceKm: 2000,
|
||||||
|
deadlineSimTime: 1_000_000,
|
||||||
|
status,
|
||||||
|
planeId: null,
|
||||||
|
createdSimTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||||
|
account: { cash: 20_000, reputation: 1 },
|
||||||
|
simState: { clockMs: 42 },
|
||||||
|
catalog: [],
|
||||||
|
fuelPricePerL: 0.8,
|
||||||
|
sellFraction: 0.5,
|
||||||
|
planes: [],
|
||||||
|
packages,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sseFrame', () => {
|
||||||
|
it('keeps exactly the open/loaded/expired packages and drops terminal history', () => {
|
||||||
|
const all = [
|
||||||
|
pkg('open', 'p-open'),
|
||||||
|
pkg('delivered', 'p-delivered'),
|
||||||
|
pkg('loaded', 'p-loaded'),
|
||||||
|
pkg('abandoned', 'p-abandoned'),
|
||||||
|
pkg('expired', 'p-expired'),
|
||||||
|
];
|
||||||
|
const frame = sseFrame(snapshot(all));
|
||||||
|
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the rest of the snapshot through untouched and does not mutate its input', () => {
|
||||||
|
const all = [pkg('open', 'a'), pkg('delivered', 'b')];
|
||||||
|
const snap = snapshot(all);
|
||||||
|
const frame = sseFrame(snap);
|
||||||
|
expect(frame.account).toEqual({ cash: 20_000, reputation: 1 });
|
||||||
|
expect(frame.simState).toEqual({ clockMs: 42 });
|
||||||
|
expect(frame.planes).toEqual([]);
|
||||||
|
expect(all.map((p) => p.status)).toContain('delivered'); // input intact
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE_PACKAGE_STATUSES matches what the client renders (open market, on-board cargo)', () => {
|
||||||
|
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+53
-19
@@ -5,6 +5,7 @@ import { getGameDb } from '@/lib/db/client';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||||
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||||
|
import { JOB_LIFETIME_MS } from './seed';
|
||||||
|
|
||||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||||
|
|
||||||
@@ -49,6 +50,21 @@ export function parseIcaoQueue(raw: string | null): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination
|
||||||
|
// joins the pool while airborne), or queued for it (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 pools until one frees up. Shared by the snapshot's visibility filter and the engine's
|
||||||
|
// pool spawner so both agree on exactly which airports keep open jobs.
|
||||||
|
export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set<string> {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const p of planes) {
|
||||||
|
if (p.status === 'available') set.add(p.locationIcao);
|
||||||
|
else if (p.status === 'flying' && p.destIcao) set.add(p.destIcao);
|
||||||
|
for (const icao of parseIcaoQueue(p.queue)) set.add(icao);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PackageState {
|
export interface PackageState {
|
||||||
id: string;
|
id: string;
|
||||||
originIcao: string;
|
originIcao: string;
|
||||||
@@ -65,10 +81,12 @@ export interface PackageState {
|
|||||||
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
|
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
|
||||||
distanceKm: number;
|
distanceKm: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
|
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
|
||||||
|
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
||||||
|
expiresRealMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateSnapshot {
|
export interface StateSnapshot {
|
||||||
@@ -85,6 +103,16 @@ export interface StateSnapshot {
|
|||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
|
||||||
|
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
|
||||||
|
// Terminal history (delivered/abandoned) is unbounded in the DB and never read by the
|
||||||
|
// client, so it stays out of every pushed frame; /api/state and action responses keep the full set.
|
||||||
|
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
|
||||||
|
|
||||||
|
export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
|
||||||
|
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
||||||
@@ -111,6 +139,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
|
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.65.0: pool visibility — open packages appear only where a plane can take work (poolAirports);
|
||||||
|
// every other status is always visible (on-board cargo, history). Filtered rows are absent from the
|
||||||
|
// body entirely, so clients never see jobs they could not dispatch.
|
||||||
|
const pool = poolAirports(planeRows);
|
||||||
|
|
||||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||||
// departure values — live position and fuel are pure functions of the clock.
|
// departure values — live position and fuel are pure functions of the clock.
|
||||||
const livePlane = (p: schema.PlaneRow) => {
|
const livePlane = (p: schema.PlaneRow) => {
|
||||||
@@ -161,22 +194,23 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
repairUntilSimTime: p.repairUntilSimTime,
|
repairUntilSimTime: p.repairUntilSimTime,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
packages: packageRows.map((p) => ({
|
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
originIcao: p.originIcao,
|
originIcao: p.originIcao,
|
||||||
originName: nameOf(p.originIcao),
|
originName: nameOf(p.originIcao),
|
||||||
destIcao: p.destIcao,
|
destIcao: p.destIcao,
|
||||||
destName: nameOf(p.destIcao),
|
destName: nameOf(p.destIcao),
|
||||||
reward: p.reward,
|
reward: p.reward,
|
||||||
cargoType: p.cargoType,
|
cargoType: p.cargoType,
|
||||||
cargoCount: p.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
weightKg: p.weightKg,
|
weightKg: p.weightKg,
|
||||||
special: p.special,
|
special: p.special,
|
||||||
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
|
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
|
||||||
deadlineSimTime: p.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
createdSimTime: p.createdSimTime,
|
createdSimTime: p.createdSimTime,
|
||||||
})),
|
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-1
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm } from './geo';
|
||||||
|
import { loadAirportsGeoJson } from './airports';
|
||||||
|
import { positionAt } from './sim/movement';
|
||||||
|
import { PLANE_TYPES } from './game/catalog';
|
||||||
|
|
||||||
const AIRPORTS = new Map<string, AirportCoord>([
|
const AIRPORTS = new Map<string, AirportCoord>([
|
||||||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||||||
@@ -45,7 +48,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
|
|
||||||
describe('planesGeoJson', () => {
|
describe('planesGeoJson', () => {
|
||||||
it('returns an empty collection for a null snapshot', () => {
|
it('returns an empty collection for a null snapshot', () => {
|
||||||
expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(planesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
||||||
@@ -56,6 +59,7 @@ describe('planesGeoJson', () => {
|
|||||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fc.features).toHaveLength(2);
|
expect(fc.features).toHaveLength(2);
|
||||||
@@ -82,6 +86,7 @@ describe('planesGeoJson', () => {
|
|||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||||||
});
|
});
|
||||||
@@ -93,11 +98,116 @@ describe('planesGeoJson', () => {
|
|||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
||||||
);
|
);
|
||||||
expect(fc.features[0].properties.routeText).toBe('');
|
expect(fc.features[0].properties.routeText).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () => {
|
||||||
|
const nzaa = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
|
||||||
|
const nftf = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — NZAA→NFTF crosses ±180 eastward
|
||||||
|
|
||||||
|
it('eastward crossing: a mid-leg wrapped lon is unwrapped against the origin airport', () => {
|
||||||
|
// positionAt wraps into [−180, 180]: past ±180 the snapshot reads −179.5 for a point at +180.5.
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
const [lon] = fc.features[0].geometry.coordinates;
|
||||||
|
expect(lon).toBeCloseTo(180.5, 9); // −179.5 + 360 — continuous with the origin's +174.8
|
||||||
|
expect(Math.abs(lon - nzaa.lon)).toBeLessThan(180); // within half a turn of the anchor
|
||||||
|
});
|
||||||
|
|
||||||
|
it('westward crossing (reversed pair): unwrapped below −180', () => {
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
const [lon] = fc.features[0].geometry.coordinates;
|
||||||
|
expect(lon).toBeCloseTo(-180.5, 9); // +179.5 − 360 — continuous with the origin's −175.1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marker lon equals the route line\'s own sample at every progress point (same world copy)', () => {
|
||||||
|
// The line is greatCircleArc(NZAA→NFTF) unwrapped from +174.8; feed each of its samples back as a
|
||||||
|
// WRAPPED snapshot position (what positionAt produces server-side) — the marker must land exactly
|
||||||
|
// on the line's sample, so marker and line agree frame to frame across the crossing.
|
||||||
|
const arc = greatCircleArc(nzaa, nftf);
|
||||||
|
for (const [uLon, uLat] of arc) {
|
||||||
|
let wLon = uLon; // wrap into positionAt's range as the server snapshot would carry it
|
||||||
|
while (wLon > 180) wLon -= 360;
|
||||||
|
while (wLon <= -180) wLon += 360;
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features[0].geometry.coordinates[0]).toBeCloseTo(uLon, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grounded planes keep their raw position (no unwrap without a leg)', () => {
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features[0].geometry.coordinates).toEqual([nftf.lon, nftf.lat]); // raw −175.1, untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a flying plane with a missing endpoint keeps its wrapped lon (no line is drawn either)', () => {
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features[0].geometry.coordinates).toEqual([-179.5, -29.5]); // wrapped — mirrors routesGeoJson's skip
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-crossing legs are untouched: lons stay as polled', () => {
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features[0].geometry.coordinates).toEqual([-1.9, 45.2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the exact properties e2e starterMarker matches on: id + flying', () => {
|
||||||
|
const fc = planesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
const props = fc.features[0].properties!;
|
||||||
|
expect(props.id).toBe('plane-starter'); // starterMarker's match key
|
||||||
|
expect(props.flying).toBe(true); // the flag the e2e poll waits on
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
|
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
|
||||||
it('returns an empty collection for a null snapshot', () => {
|
it('returns an empty collection for a null snapshot', () => {
|
||||||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
@@ -396,3 +506,39 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
|||||||
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
|
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (ISS-124)', () => {
|
||||||
|
// The exact production pipeline for one flying frame: the server snapshot holds positionAt's WRAPPED
|
||||||
|
// lat/lon (atan2 → [−180, 180]); planesGeoJson re-expresses lon against the departure airport
|
||||||
|
// (locationIcao is always the leg origin while flying — lib/game/state.ts). This pins what lons the
|
||||||
|
// e2e marker sampling can EVER see for this dataset's stored coords.
|
||||||
|
it('marker lons cross above +180 eastward, continuously', async () => {
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const nzaa = fc.features.find((f) => f.properties.icao === 'NZAA')!.properties;
|
||||||
|
const nftf = fc.features.find((f) => f.properties.icao === 'NFTF')!.properties;
|
||||||
|
expect(nzaa.lon).toBeGreaterThan(170); // stored east-positive (Auckland +174.8, not −175.x)
|
||||||
|
expect(nftf.lon).toBeLessThan(-170);
|
||||||
|
|
||||||
|
const km = haversineKm(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon);
|
||||||
|
const index = new Map<string, AirportCoord>([
|
||||||
|
['NZAA', { lat: nzaa.lat, lon: nzaa.lon }],
|
||||||
|
['NFTF', { lat: nftf.lat, lon: nftf.lon }],
|
||||||
|
]);
|
||||||
|
const lons: number[] = [];
|
||||||
|
for (let i = 0; i <= 50; i++) {
|
||||||
|
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
|
||||||
|
const snap = snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
||||||
|
}
|
||||||
|
console.log(`NZAA→NFTF (${km.toFixed(1)} km): sampled marker lons ${JSON.stringify(lons.map((x) => Number(x.toFixed(2))))}`);
|
||||||
|
for (let i = 1; i < lons.length; i++) {
|
||||||
|
expect(Math.abs(lons[i]! - lons[i - 1]!), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
|
||||||
|
}
|
||||||
|
expect(Math.max(...lons)).toBeGreaterThan(180); // eastward crossing → onto the + world copy
|
||||||
|
expect(Math.min(...lons)).toBeGreaterThanOrEqual(nzaa.lon - 1e-9); // never wraps to the west copy
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+24
-5
@@ -41,15 +41,30 @@ export interface FeatureCollection<T> {
|
|||||||
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
||||||
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
||||||
|
|
||||||
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
// ISS-124 (v0.58.0): positionAt wraps each sample's longitude into [−180, 180], so a marker on a
|
||||||
|
// leg crossing the antimeridian jumps +179.x → −179.x between polls — MapLibre draws that as a 360°
|
||||||
|
// teleport while the route line (greatCircleArc) stays continuous. Unwrap the plane's lon against
|
||||||
|
// its departure airport — the same anchor greatCircleArc keeps for its first sample — so marker and
|
||||||
|
// line agree frame to frame. Only when BOTH endpoints exist, mirroring routesGeoJson's guard: a
|
||||||
|
// flying plane with a missing endpoint draws no line either, so it stays wrapped like before.
|
||||||
|
export function planesGeoJson(
|
||||||
|
state: StateSnapshot | null,
|
||||||
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
|
): FeatureCollection<MapPointFeature> {
|
||||||
if (!state) return EMPTY_POINTS;
|
if (!state) return EMPTY_POINTS;
|
||||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
||||||
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||||
const flying = p.status === 'flying';
|
const flying = p.status === 'flying';
|
||||||
|
let lon = p.lon;
|
||||||
|
if (flying && p.destIcao) {
|
||||||
|
const from = airportsByIcao.get(p.locationIcao);
|
||||||
|
const to = airportsByIcao.get(p.destIcao);
|
||||||
|
if (from && to) lon = unwrapLon(p.lon, from.lon);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
geometry: { type: 'Point', coordinates: [lon, p.lat] },
|
||||||
properties: {
|
properties: {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
modelLabel: labelByModel.get(p.model) ?? p.model,
|
modelLabel: labelByModel.get(p.model) ?? p.model,
|
||||||
@@ -80,12 +95,16 @@ export const ROUTE_ARC_SEGMENTS = 24;
|
|||||||
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
|
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
|
||||||
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
|
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
|
||||||
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
|
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
|
||||||
|
function unwrapLon(lon: number, prev: number): number {
|
||||||
|
while (lon - prev > 180) lon -= 360;
|
||||||
|
while (prev - lon > 180) lon += 360;
|
||||||
|
return lon;
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapLons(coords: [number, number][]): [number, number][] {
|
function unwrapLons(coords: [number, number][]): [number, number][] {
|
||||||
let prev = coords[0]![0];
|
let prev = coords[0]![0];
|
||||||
for (let i = 1; i < coords.length; i++) {
|
for (let i = 1; i < coords.length; i++) {
|
||||||
let lon = coords[i]![0];
|
const lon = unwrapLon(coords[i]![0], prev);
|
||||||
while (lon - prev > 180) lon -= 360;
|
|
||||||
while (prev - lon > 180) lon += 360;
|
|
||||||
coords[i]![0] = lon;
|
coords[i]![0] = lon;
|
||||||
prev = lon;
|
prev = lon;
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-3
@@ -3,13 +3,18 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => {
|
it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.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}/ignore', // v0.29.0 (ISS-047 part B)
|
|
||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
@@ -136,6 +141,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'destIcao',
|
'destIcao',
|
||||||
'destName',
|
'destName',
|
||||||
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||||||
|
'expiresRealMs', // v0.65.0 — wall-clock expiry for open jobs; null otherwise
|
||||||
'id',
|
'id',
|
||||||
'originIcao',
|
'originIcao',
|
||||||
'originName',
|
'originName',
|
||||||
@@ -151,7 +157,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
|
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
|
||||||
|
|
||||||
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
||||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
|
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||||
@@ -241,4 +247,53 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(sell.required).toEqual(['planeId']);
|
expect(sell.required).toEqual(['planeId']);
|
||||||
expect(sell.properties.planeId.type).toBe('string');
|
expect(sell.properties.planeId.type).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
|
||||||
|
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
|
||||||
|
|
||||||
|
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
|
||||||
|
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
|
||||||
|
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
|
||||||
|
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
|
||||||
|
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
|
||||||
|
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
|
||||||
|
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||||
|
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
|
||||||
|
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
|
||||||
|
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success shapes.
|
||||||
|
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
|
||||||
|
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
|
||||||
|
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
|
||||||
|
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
|
||||||
|
expect(tables.type).toBe('array');
|
||||||
|
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
|
||||||
|
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
|
||||||
|
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
|
||||||
|
|
||||||
|
// Request bodies + path parameter.
|
||||||
|
const bodyRef = (p: string, m: string) =>
|
||||||
|
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
|
||||||
|
?.schema.$ref;
|
||||||
|
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
|
||||||
|
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
|
||||||
|
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
|
||||||
|
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||||
|
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request schema shapes.
|
||||||
|
const login = openApiSpec.components.schemas.LoginRequest;
|
||||||
|
expect(login.required).toEqual(['username', 'password']);
|
||||||
|
expect(login.properties.username.type).toBe('string');
|
||||||
|
expect(login.properties.password.type).toBe('string');
|
||||||
|
const sessionUser = openApiSpec.components.schemas.SessionUser;
|
||||||
|
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
|
||||||
|
expect(sessionUser.properties.admin.type).toBe('boolean');
|
||||||
|
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
|
||||||
|
expect(updateRow.required).toEqual(['rowid', 'values']);
|
||||||
|
expect(updateRow.properties.rowid.type).toBe('integer');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+344
-29
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'Flight Ops API',
|
title: 'Flight Ops API',
|
||||||
description:
|
description:
|
||||||
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -51,6 +51,22 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/events': {
|
||||||
|
get: {
|
||||||
|
summary: 'Live state stream (SSE)',
|
||||||
|
description:
|
||||||
|
'Server-Sent Events feed of full game-state snapshots (v0.57.0, ISS-129) — the current snapshot is sent immediately on connect and a fresh one after every sim tick (~1 s), so clients can subscribe instead of polling /api/state. Each event\'s data payload is JSON with exactly the StateSnapshot shape; comment lines starting with ":" are heartbeats.',
|
||||||
|
operationId: 'subscribeToEvents',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'SSE stream of state snapshots (text/event-stream)',
|
||||||
|
content: {
|
||||||
|
'text/event-stream': { schema: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api/version': {
|
'/api/version': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'App version',
|
summary: 'App version',
|
||||||
@@ -131,31 +147,6 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/packages/{id}/ignore': {
|
|
||||||
post: {
|
|
||||||
summary: 'Ignore an open job',
|
|
||||||
description:
|
|
||||||
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
|
|
||||||
operationId: 'ignorePackage',
|
|
||||||
parameters: [
|
|
||||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: 'Unknown package id',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
|
||||||
},
|
|
||||||
409: {
|
|
||||||
description: 'Package not "open"',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/api/planes/{planeId}/fly': {
|
'/api/planes/{planeId}/fly': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Fly a plane to an airport',
|
summary: 'Fly a plane to an airport',
|
||||||
@@ -351,7 +342,244 @@ export const openApiSpec = {
|
|||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
|
||||||
|
'/api/login': {
|
||||||
|
post: {
|
||||||
|
summary: 'Log in',
|
||||||
|
description:
|
||||||
|
'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.',
|
||||||
|
operationId: 'login',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/LoginRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Authenticated — new session id + the user',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Unknown username or wrong password',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/logout': {
|
||||||
|
post: {
|
||||||
|
summary: 'Log out',
|
||||||
|
description:
|
||||||
|
'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.',
|
||||||
|
operationId: 'logout',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Session deleted (or was already gone)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing X-Flight-Session header',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/me': {
|
||||||
|
get: {
|
||||||
|
summary: 'Current user',
|
||||||
|
description:
|
||||||
|
'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.',
|
||||||
|
operationId: 'getMe',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'The authenticated user',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/admin/tables': {
|
||||||
|
get: {
|
||||||
|
summary: 'List database tables (admin)',
|
||||||
|
description:
|
||||||
|
'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.',
|
||||||
|
operationId: 'listAdminTables',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'The table names',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Valid session but the user is not an admin',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/admin/tables/{name}/rows': {
|
||||||
|
get: {
|
||||||
|
summary: 'Read table rows (admin)',
|
||||||
|
description:
|
||||||
|
'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.',
|
||||||
|
operationId: 'listAdminRows',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Column headers + rows (each row carries its __rowid)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'limit is not a positive integer',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Valid session but the user is not an admin',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown table name',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
summary: 'Update a row (admin)',
|
||||||
|
description:
|
||||||
|
'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.',
|
||||||
|
operationId: 'updateAdminRow',
|
||||||
|
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/UpdateRowRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Updated',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Invalid body, non-integer rowid, unknown column, or empty values',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Valid session but the user is not an admin',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown table name',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
summary: 'Insert a row (admin)',
|
||||||
|
description:
|
||||||
|
'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.',
|
||||||
|
operationId: 'insertAdminRow',
|
||||||
|
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/InsertRowRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Inserted — the new rowid',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Invalid body, unknown column, or empty values',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Valid session but the user is not an admin',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown table name',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Database constraint violation (NOT NULL / UNIQUE)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: 'Delete a row (admin)',
|
||||||
|
description:
|
||||||
|
'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.',
|
||||||
|
operationId: 'deleteAdminRow',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Deleted (or was already gone)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Missing or non-integer rowid query parameter',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Missing, unknown or expired session',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Valid session but the user is not an admin',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown table name',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -505,6 +733,92 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['planeId'],
|
required: ['planeId'],
|
||||||
},
|
},
|
||||||
|
// v0.71.0 — admin panel auth + DB tooling schemas.
|
||||||
|
LoginRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string', description: 'Account username (non-empty).' },
|
||||||
|
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
|
||||||
|
},
|
||||||
|
required: ['username', 'password'],
|
||||||
|
},
|
||||||
|
SessionUser: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string' },
|
||||||
|
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
|
||||||
|
},
|
||||||
|
required: ['username', 'admin'],
|
||||||
|
},
|
||||||
|
LoginResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
|
||||||
|
user: { $ref: '#/components/schemas/SessionUser' },
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'user'],
|
||||||
|
},
|
||||||
|
OkResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { const: true },
|
||||||
|
},
|
||||||
|
required: ['ok'],
|
||||||
|
},
|
||||||
|
AdminTable: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
AdminRowsResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
columns: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object', additionalProperties: true },
|
||||||
|
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['columns', 'rows'],
|
||||||
|
},
|
||||||
|
UpdateRowRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
|
||||||
|
values: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['rowid', 'values'],
|
||||||
|
},
|
||||||
|
InsertRowRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
values: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description: 'Exactly the columns to set; others take their defaults or NULL.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['values'],
|
||||||
|
},
|
||||||
|
InsertRowResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { const: true },
|
||||||
|
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
|
||||||
|
},
|
||||||
|
required: ['ok', 'rowid'],
|
||||||
|
},
|
||||||
Error: {
|
Error: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -588,10 +902,10 @@ export const openApiSpec = {
|
|||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
|
||||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
|
|
||||||
planeId: { type: ['string', 'null'] },
|
planeId: { type: ['string', 'null'] },
|
||||||
createdSimTime: { type: 'integer' },
|
createdSimTime: { type: 'integer' },
|
||||||
|
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -608,6 +922,7 @@ export const openApiSpec = {
|
|||||||
'status',
|
'status',
|
||||||
'planeId',
|
'planeId',
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
|
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+192
-106
@@ -10,17 +10,17 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
import { SPECIALS } from '@/lib/game/specials';
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed';
|
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expireLoadedPackages,
|
||||||
|
expireOpenPackages,
|
||||||
finishRepairs,
|
finishRepairs,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
reputationGainFor,
|
reputationGainFor,
|
||||||
shouldAdvanceTime,
|
shouldAdvanceTime,
|
||||||
spawnCoveragePackages,
|
spawnPoolPackages,
|
||||||
spawnOpenPackages,
|
|
||||||
tickOnce,
|
tickOnce,
|
||||||
WORLD_TIME_SCALE,
|
WORLD_TIME_SCALE,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
@@ -30,8 +30,9 @@ import {
|
|||||||
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
||||||
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
|
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
|
||||||
|
|
||||||
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
|
// #135: fresh DBs seed a GROUNDED starter, so any test that needs clock advance must put a plane in
|
||||||
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
|
// flight first (a fully grounded fleet freezes the world) — departureSimTime at the game-clock
|
||||||
|
// origin keeps its arrival hours away.
|
||||||
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
||||||
@@ -44,11 +45,15 @@ const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(T0);
|
vi.setSystemTime(T0);
|
||||||
|
// #135 (v0.62.0): this file exercises the FLIGHT_TEST_HOOKS branch of tickOnce — stored
|
||||||
|
// sim.timeScale overrides are only honored when the env var is set, exactly like CI.
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ async function freshDb(name: string) {
|
|||||||
describe('tickOnce — clock advance', () => {
|
describe('tickOnce — clock advance', () => {
|
||||||
it('advances the clock by elapsed real ms × timeScale', async () => {
|
it('advances the clock by elapsed real ms × timeScale', async () => {
|
||||||
const inst = await freshDb('scale.db');
|
const inst = await freshDb('scale.db');
|
||||||
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117)
|
await makeWorldActive(inst); // a fully grounded fleet freezes the clock (#135)
|
||||||
vi.setSystemTime(T0 + 1_000); // one real second later
|
vi.setSystemTime(T0 + 1_000); // one real second later
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
||||||
|
|
||||||
@@ -70,15 +75,33 @@ describe('tickOnce — clock advance', () => {
|
|||||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
|
it('production (no FLIGHT_TEST_HOOKS): a legacy stored timeScale is ignored — always WORLD_TIME_SCALE (#135)', async () => {
|
||||||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
|
const inst = await freshDb('prod-rate.db');
|
||||||
|
await makeWorldActive(inst);
|
||||||
|
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // what a pre-v0.52.0 save holds
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 1_000);
|
||||||
|
const saved = process.env.FLIGHT_TEST_HOOKS;
|
||||||
|
delete process.env.FLIGHT_TEST_HOOKS;
|
||||||
|
let sim: schema.SimStateRow;
|
||||||
|
try {
|
||||||
|
sim = await tickOnce(inst);
|
||||||
|
} finally {
|
||||||
|
if (saved !== undefined) process.env.FLIGHT_TEST_HOOKS = saved; // restore the stubbed value
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // ×24, NOT ×1000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a fully grounded fleet freezes the clock, then resumes with no catch-up (#135)', async () => {
|
||||||
|
const inst = await freshDb('idle.db'); // seeded starter is on the ground → frozen
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
||||||
let sim = await tickOnce(inst);
|
let sim = await tickOnce(inst);
|
||||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no fast-forward, no idle creep
|
||||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
||||||
|
|
||||||
// A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
|
// A plane takes off: resumes from where it stopped, with NO catch-up for the frozen time.
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 60 });
|
await inst.db.update(schema.simState).set({ timeScale: 60 });
|
||||||
await makeWorldActive(inst);
|
await makeWorldActive(inst);
|
||||||
vi.setSystemTime(T0 + 3_600_500);
|
vi.setSystemTime(T0 + 3_600_500);
|
||||||
@@ -86,6 +109,16 @@ describe('tickOnce — clock advance', () => {
|
|||||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a grounded fleet ignores any raised timeScale — frozen regardless (#135)', async () => {
|
||||||
|
const inst = await freshDb('idle-scale.db');
|
||||||
|
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 1_000);
|
||||||
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen, NOT ×1000
|
||||||
|
});
|
||||||
|
|
||||||
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
|
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
|
||||||
const inst = await freshDb('clamp.db');
|
const inst = await freshDb('clamp.db');
|
||||||
await makeWorldActive(inst);
|
await makeWorldActive(inst);
|
||||||
@@ -107,41 +140,68 @@ describe('tickOnce — clock advance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('expirePackages', () => {
|
describe('expireLoadedPackages (sim clock)', () => {
|
||||||
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
|
it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => {
|
||||||
const inst = await freshDb('expiry.db');
|
const inst = await freshDb('expiry-loaded.db');
|
||||||
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
|
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
|
||||||
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
|
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
|
||||||
const clock = STARTING_CLOCK_MS + 100_000;
|
const clock = STARTING_CLOCK_MS + 100_000;
|
||||||
|
|
||||||
await inst.db.insert(schema.packages).values([
|
await inst.db.insert(schema.packages).values([
|
||||||
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
|
||||||
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
|
||||||
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
||||||
|
{ id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const n = await expirePackages(inst, clock);
|
const n = await expireLoadedPackages(inst, clock);
|
||||||
expect(n).toBe(2); // the open and the loaded one
|
expect(n).toBe(1); // only the loaded one — open jobs live on the wall clock now (v0.65.0)
|
||||||
|
|
||||||
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
|
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
|
||||||
expect(byId.get('p-open-past')).toBe('expired');
|
|
||||||
expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet
|
|
||||||
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
||||||
|
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
|
||||||
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||||
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||||
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a deadline exactly at the clock counts as expired', async () => {
|
it('a deadline exactly at the clock counts as expired', async () => {
|
||||||
const inst = await freshDb('expiry-edge.db');
|
const inst = await freshDb('expiry-loaded-edge.db');
|
||||||
|
await inst.db.insert(schema.packages).values({
|
||||||
|
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
|
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS,
|
||||||
|
});
|
||||||
|
expect(await expireLoadedPackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expireOpenPackages (v0.65.0 wall clock)', () => {
|
||||||
|
it('flips open packages past their real-time lifetime; fresh ones and terminal statuses never', async () => {
|
||||||
|
const inst = await freshDb('expiry-open.db');
|
||||||
|
// T0 is the fake Date.now() at DB open — seeded rows carry createdRealMs = T0, so they are FRESH
|
||||||
|
// (a full JOB_LIFETIME_MS away) and only the aged row below can expire.
|
||||||
|
await inst.db.insert(schema.packages).values([
|
||||||
|
{ id: 'p-open-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
|
||||||
|
{ id: 'p-open-fresh', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 },
|
||||||
|
{ id: 'p-delivered-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n = await expireOpenPackages(inst, T0);
|
||||||
|
expect(n).toBe(1); // only the aged open one — loaded jobs are sim-clock territory now
|
||||||
|
|
||||||
|
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
|
||||||
|
expect(byId.get('p-open-aged')).toBe('expired');
|
||||||
|
expect(byId.get('p-open-fresh')).toBe('open'); // lifetime not reached yet
|
||||||
|
expect(byId.get('p-delivered-aged')).toBe('delivered'); // terminal — never flips
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a lifetime exactly at the clock counts as expired', async () => {
|
||||||
|
const inst = await freshDb('expiry-open-edge.db');
|
||||||
await inst.db.insert(schema.packages).values({
|
await inst.db.insert(schema.packages).values({
|
||||||
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
|
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
|
||||||
|
createdRealMs: T0 - JOB_LIFETIME_MS,
|
||||||
});
|
});
|
||||||
expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
|
expect(await expireOpenPackages(inst, T0)).toBe(1); // <= is inclusive
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -574,8 +634,12 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
|
describe('grounded fleet freezes the world clock (#135)', () => {
|
||||||
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
|
it('pins WORLD_TIME_SCALE = 24 — the single fixed world rate (ISS-117)', () => {
|
||||||
|
expect(WORLD_TIME_SCALE).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shouldAdvanceTime: grounded/empty fleets are frozen; any flying or repairing plane advances', () => {
|
||||||
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||||||
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||||||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
|
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
|
||||||
@@ -583,14 +647,14 @@ describe('idle-pause — the world freezes when nothing is happening (ISS-117)',
|
|||||||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
|
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
|
it('a fully grounded fleet stays frozen but keeps tracking real time', async () => {
|
||||||
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
|
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → frozen
|
||||||
vi.setSystemTime(T0 + 3_600_000);
|
vi.setSystemTime(T0 + 3_600_000);
|
||||||
|
|
||||||
const sim = await tickOnce(inst);
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no idle creep
|
||||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
|
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a repairing plane keeps the world advancing', async () => {
|
it('a repairing plane keeps the world advancing', async () => {
|
||||||
@@ -604,11 +668,31 @@ describe('idle-pause — the world freezes when nothing is happening (ISS-117)',
|
|||||||
|
|
||||||
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
|
||||||
|
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
|
||||||
|
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
|
||||||
|
// Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
|
||||||
|
// empty", not "taken" — a real load stamps the cooldown and would block this refill for REFILL_DELAY_MS.
|
||||||
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
|
if (p.status === 'open') {
|
||||||
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 1_000); // a background tick while the fleet is grounded
|
||||||
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // freeze preserved…
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // …but the starter's pool is refilled to its full composition…
|
||||||
|
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // …all at the grounded starter's airport
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('spawnOpenPackages', () => {
|
describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||||
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
|
it('tops each pool airport up to the POOL_BANDS composition using the shared seed builder', async () => {
|
||||||
const inst = await freshDb('spawn.db');
|
const inst = await freshDb('pool.db');
|
||||||
const clock = T0 + 123_456;
|
const clock = T0 + 123_456;
|
||||||
|
|
||||||
// Leave exactly 3 open packages (the rest of the seeded set is "done").
|
// Leave exactly 3 open packages (the rest of the seeded set is "done").
|
||||||
@@ -617,16 +701,18 @@ describe('spawnOpenPackages', () => {
|
|||||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
|
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const needed = await spawnOpenPackages(inst, clock, mulberry32(7));
|
const needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0);
|
||||||
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3);
|
expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3);
|
||||||
|
|
||||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // single grounded plane → one pool
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
||||||
for (const p of openRows.slice(3)) { // the spawned ones
|
for (const p of openRows.slice(3)) { // the spawned ones
|
||||||
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
|
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
|
||||||
|
expect(p.createdRealMs).toBe(T0); // …and stamped with the injected wall clock
|
||||||
expect(icaoSet.has(p.originIcao)).toBe(true);
|
expect(icaoSet.has(p.originIcao)).toBe(true);
|
||||||
expect(icaoSet.has(p.destIcao)).toBe(true);
|
expect(icaoSet.has(p.destIcao)).toBe(true);
|
||||||
expect(p.originIcao).not.toBe(p.destIcao);
|
expect(p.originIcao).not.toBe(p.destIcao);
|
||||||
@@ -638,15 +724,36 @@ describe('spawnOpenPackages', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Already at target → nothing more to spawn.
|
// Already at target → nothing more to spawn.
|
||||||
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
|
expect(await spawnPoolPackages(inst, clock, mulberry32(7), T0)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
|
it('keeps the band composition per airport — weights stay inside their band', async () => {
|
||||||
const a = await freshDb('spawn-a.db');
|
const inst = await freshDb('pool-composition.db');
|
||||||
const b = await freshDb('spawn-b.db');
|
// Wipe all seeded packages so the spawner builds the pool from zero with a known RNG.
|
||||||
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
|
||||||
|
expect(n).toBe(POOL_TARGET_PER_AIRPORT); // one pool (EDDF) at full composition
|
||||||
|
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
const byBand = [0, 0, 0, 0];
|
||||||
|
for (const p of openRows) {
|
||||||
|
const b = poolBandOf(p.weightKg);
|
||||||
|
byBand[b]++;
|
||||||
|
expect(p.weightKg).toBeGreaterThanOrEqual(POOL_BANDS[b].minKg); // weight stays in its band…
|
||||||
|
expect(p.weightKg).toBeLessThanOrEqual(POOL_BANDS[b].maxKg); // …on both ends
|
||||||
|
}
|
||||||
|
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for an injected RNG and wall clock', async () => {
|
||||||
|
const a = await freshDb('pool-a.db');
|
||||||
|
const b = await freshDb('pool-b.db');
|
||||||
const clock = T0 + 999;
|
const clock = T0 + 999;
|
||||||
|
|
||||||
// Wipe all packages in both so both spawn from zero with the same RNG.
|
// Wipe all packages in both so both spawn from zero with the same RNG and now.
|
||||||
for (const inst of [a, b]) {
|
for (const inst of [a, b]) {
|
||||||
const rows = await inst.db.select().from(schema.packages);
|
const rows = await inst.db.select().from(schema.packages);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -654,8 +761,8 @@ describe('spawnOpenPackages', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
|
||||||
const strip = (rows: schema.PackageRow[]) =>
|
const strip = (rows: schema.PackageRow[]) =>
|
||||||
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
||||||
@@ -663,82 +770,62 @@ describe('spawnOpenPackages', () => {
|
|||||||
strip(await b.db.select().from(schema.packages)),
|
strip(await b.db.select().from(schema.packages)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
it('spawns for flying destinations and queued airports, not just grounded locations', async () => {
|
||||||
// The seeded starter plane is grounded at EDDF with no seeded package from
|
const inst = await freshDb('pool-membership.db');
|
||||||
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
|
|
||||||
|
|
||||||
it('spawns an open package originating at the airport of each uncovered grounded plane', async () => {
|
|
||||||
const inst = await freshDb('coverage.db');
|
|
||||||
const clock = T0 + 5_000;
|
|
||||||
|
|
||||||
// Wipe all seeded packages so coverage is the only source of new ones.
|
|
||||||
for (const p of await inst.db.select().from(schema.packages)) {
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
|
// Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is
|
||||||
const n = await spawnCoveragePackages(inst, clock, mulberry32(7));
|
// {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it.
|
||||||
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
||||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
expect(openRows.length).toBe(1);
|
await inst.db.insert(schema.planes).values({
|
||||||
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
|
id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
|
||||||
expect(openRows[0].createdSimTime).toBe(clock);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when every grounded airport already has an open package', async () => {
|
|
||||||
const inst = await freshDb('coverage-covered.db');
|
|
||||||
for (const p of await inst.db.select().from(schema.packages)) {
|
|
||||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
|
||||||
}
|
|
||||||
// One open package from EDDF covers the starter plane's airport.
|
|
||||||
await inst.db.insert(schema.packages).values({
|
|
||||||
id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
|
||||||
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0);
|
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
|
||||||
expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added
|
expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set
|
||||||
});
|
|
||||||
|
|
||||||
it('spawns one package per uncovered airport when planes sit at several airports', async () => {
|
|
||||||
const inst = await freshDb('coverage-multi.db');
|
|
||||||
for (const p of await inst.db.select().from(schema.packages)) {
|
|
||||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
|
||||||
}
|
|
||||||
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
|
|
||||||
await inst.db.insert(schema.planes).values([
|
|
||||||
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null },
|
|
||||||
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7));
|
|
||||||
expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD
|
|
||||||
|
|
||||||
const origins = (await inst.db.select().from(schema.packages))
|
const origins = (await inst.db.select().from(schema.packages))
|
||||||
.filter((p) => p.status === 'open')
|
.filter((p) => p.status === 'open')
|
||||||
.map((p) => p.originIcao)
|
.map((p) => p.originIcao)
|
||||||
.sort();
|
.sort();
|
||||||
expect(origins).toEqual(['EDDF', 'LEMD']);
|
expect(origins).toEqual(
|
||||||
|
[...Array(POOL_TARGET_PER_AIRPORT).fill('EHAM'), ...Array(POOL_TARGET_PER_AIRPORT).fill('LEMD')].sort(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores flying planes — they cannot take new work until they land', async () => {
|
it('holds taken slots for their refill delay, then refills them', async () => {
|
||||||
const inst = await freshDb('coverage-flying.db');
|
const inst = await freshDb('pool-cooldown.db');
|
||||||
for (const p of await inst.db.select().from(schema.packages)) {
|
// Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport
|
||||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
// cooldown — see the ponytail note in spawnPoolPackages).
|
||||||
}
|
const all = await inst.db.select().from(schema.packages);
|
||||||
await inst.db.update(schema.planes)
|
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
|
||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
|
||||||
|
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
|
||||||
|
|
||||||
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
|
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
|
||||||
|
|
||||||
|
// The small slot frees first (SMALL_REFILL_DELAY_MS) — exactly its missing count refills; the heavy
|
||||||
|
// one is still held by REFILL_DELAY_MS.
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + SMALL_REFILL_DELAY_MS)).toBe(1);
|
||||||
|
|
||||||
|
// The heavy slot frees at REFILL_DELAY_MS — the last empty band tops up too.
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + REFILL_DELAY_MS)).toBe(1);
|
||||||
|
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // pool back to full composition
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
|
it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => {
|
||||||
const inst = await freshDb('coverage-tick.db');
|
const inst = await freshDb('pool-tick.db');
|
||||||
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
|
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
|
||||||
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here.
|
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
|
||||||
|
// never lands here.
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
||||||
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
|
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
|
||||||
@@ -747,10 +834,9 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
|||||||
|
|
||||||
await tickOnce(inst);
|
await tickOnce(inst);
|
||||||
|
|
||||||
const fromEdff = (await inst.db.select().from(schema.packages)).filter(
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
(p) => p.status === 'open' && p.originIcao === 'EDDF',
|
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
|
||||||
);
|
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
|
||||||
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+147
-111
@@ -1,5 +1,5 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
@@ -7,12 +7,17 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
import { SPECIALS } from '@/lib/game/specials';
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { parseIcaoQueue } from '@/lib/game/state';
|
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
||||||
|
import { broadcast } from '@/lib/sse';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generatePackage,
|
JOB_LIFETIME_MS,
|
||||||
MARKET_TARGET_OPEN_PACKAGES,
|
POOL_BANDS,
|
||||||
|
REFILL_DELAY_MS,
|
||||||
|
SMALL_REFILL_DELAY_MS,
|
||||||
|
generatePoolPackage,
|
||||||
mulberry32,
|
mulberry32,
|
||||||
pickDestWithinCap,
|
poolBandOf,
|
||||||
type Rng,
|
type Rng,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
} from '@/lib/game/seed';
|
} from '@/lib/game/seed';
|
||||||
@@ -20,9 +25,12 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
|
|||||||
|
|
||||||
// Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
|
// Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
|
||||||
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
|
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
|
||||||
// same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
|
// same locked tickOnce path. Each tick advances by (real ms since last tick) × scale, clamped per
|
||||||
// per tick so long absences never fast-forward the game; while no plane is flying or in repair the
|
// tick so long absences never fast-forward the game; while no plane is flying or in repair the
|
||||||
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time.
|
// world clock stays FROZEN (#135 v0.62.0 — final ISS-117 semantics), and lastRealTickMs always
|
||||||
|
// keeps tracking real time, so resuming activity never fast-forwards the frozen span. Scale is the
|
||||||
|
// fixed WORLD_TIME_SCALE in production; only FLIGHT_TEST_HOOKS deployments honor sim.timeScale
|
||||||
|
// (the test-hook override) — legacy saves may hold stale values (#135).
|
||||||
|
|
||||||
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
|
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
|
||||||
// both the client poll interval (~1 s) and the background-tick interval, so active play advances
|
// both the client poll interval (~1 s) and the background-tick interval, so active play advances
|
||||||
@@ -30,8 +38,10 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
|
|||||||
// 2 s × scale.
|
// 2 s × scale.
|
||||||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
export const MAX_TICK_ELAPSED_MS = 2_000;
|
||||||
|
|
||||||
// ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home.
|
// ISS-117: the fixed world rate — imported for tickOnce, re-exported so server code and tests
|
||||||
export { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
// have one familiar home.
|
||||||
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
|
export { WORLD_TIME_SCALE };
|
||||||
|
|
||||||
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
||||||
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
||||||
@@ -43,13 +53,15 @@ export function reputationGainFor(km: number): number {
|
|||||||
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
|
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the
|
// Serialize ticks across concurrent requests (Next.js route handlers run in the same process): each
|
||||||
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
// tick re-reads sim_state after the previous one wrote it, so no advance is ever applied twice.
|
||||||
// no advance is ever applied twice.
|
// ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get
|
||||||
let chain: Promise<unknown> = Promise.resolve();
|
// separate module instances from Next.js, so a plain `let` would give each layer its own lock and let
|
||||||
|
// their ticks interleave against the shared DB below.
|
||||||
|
const g = globalThis as unknown as { __flightTickChain?: Promise<unknown> };
|
||||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const run = chain.then(fn);
|
const run = (g.__flightTickChain ??= Promise.resolve()).then(fn);
|
||||||
chain = run.then(
|
g.__flightTickChain = run.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
@@ -58,9 +70,10 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
|
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
|
||||||
|
|
||||||
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock
|
// #135: advance decision — pure, so unit tests feed it synthetic fleets. The world clock runs at
|
||||||
// advances only while something is happening: at least one plane flying OR in repair. A fully
|
// full scale only while something is happening (at least one plane flying OR in repair); a fully
|
||||||
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
|
// grounded fleet freezes it entirely — no expiry/delivery/repair progress until a dispatch or
|
||||||
|
// repair starts again (the market top-up still runs on frozen ticks, #135 follow-up).
|
||||||
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
||||||
let flying = 0;
|
let flying = 0;
|
||||||
let repairing = 0;
|
let repairing = 0;
|
||||||
@@ -77,25 +90,35 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
|
// #135: a fully grounded fleet freezes the world — no advance, no expiry/delivery/repair progress
|
||||||
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
|
// (the market top-up still runs on frozen ticks — see below). lastRealTickMs always tracks real
|
||||||
|
// time, so resuming activity never fast-forwards the frozen span.
|
||||||
const planeRows = await inst.db.select().from(schema.planes);
|
const planeRows = await inst.db.select().from(schema.planes);
|
||||||
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
|
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
|
||||||
|
// #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale
|
||||||
|
// timeScale (e.g. 1000 from pre-v0.52.0 UI controls, or an old e2e run against the live instance).
|
||||||
|
// Only FLIGHT_TEST_HOOKS deployments honor sim.timeScale, which POST /api/test/set-timescale writes.
|
||||||
|
const scale = process.env.FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE;
|
||||||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||||||
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
|
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
|
||||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||||
|
|
||||||
if (advanceMs > 0) {
|
if (advanceMs > 0) {
|
||||||
await expirePackages(inst, clockMs);
|
await expireLoadedPackages(inst, clockMs);
|
||||||
await deliverPackages(inst, clockMs);
|
await deliverPackages(inst, clockMs);
|
||||||
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||||
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets pool work too.
|
||||||
await finishRepairs(inst, clockMs);
|
await finishRepairs(inst, clockMs);
|
||||||
await spawnOpenPackages(inst, clockMs);
|
|
||||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
|
||||||
// at its new location within the same tick.
|
|
||||||
await spawnCoveragePackages(inst, clockMs);
|
|
||||||
}
|
}
|
||||||
|
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
|
||||||
|
// because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without
|
||||||
|
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
|
||||||
|
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
|
||||||
|
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
|
||||||
|
await expireOpenPackages(inst, nowMs);
|
||||||
|
// AFTER delivery on purpose: a plane that just landed gets work spawned at its new location within
|
||||||
|
// the same tick (its destination joins the pool while flying, and its arrival airport on landing).
|
||||||
|
await spawnPoolPackages(inst, clockMs);
|
||||||
|
|
||||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||||
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
|
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
|
||||||
@@ -105,12 +128,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
|
// v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 —
|
||||||
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
|
// it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs
|
||||||
// occupying their slot, deliver no reward on arrival, and abandon for free).
|
// expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and
|
||||||
// Delivered/abandoned packages are terminal and never flip. Returns the count.
|
// never flip. Both return the count flipped.
|
||||||
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
|
export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
|
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'loaded'));
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const p of rows) {
|
for (const p of rows) {
|
||||||
if (p.deadlineSimTime <= clockMs) {
|
if (p.deadlineSimTime <= clockMs) {
|
||||||
@@ -121,6 +144,26 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
|
||||||
|
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
|
||||||
|
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
|
||||||
|
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows
|
||||||
|
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
|
||||||
|
// lifetime, and no new ones can ever be created. Returns the count flipped.
|
||||||
|
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
|
||||||
|
// Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
|
||||||
|
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
|
||||||
|
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
|
||||||
|
let n = 0;
|
||||||
|
for (const p of rows) {
|
||||||
|
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
|
||||||
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
|
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
|
||||||
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
|
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
|
||||||
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
|
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
|
||||||
@@ -309,19 +352,28 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
|
// v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
|
||||||
// SAME builder as the first-run seed (generatePackage), so spawned packages are
|
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
|
||||||
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
|
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
|
||||||
export async function spawnOpenPackages(
|
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
|
||||||
|
// indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
|
||||||
|
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
|
||||||
|
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
|
||||||
|
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
|
||||||
|
export async function spawnPoolPackages(
|
||||||
inst: GameDb,
|
inst: GameDb,
|
||||||
clockMs: number,
|
clockMs: number,
|
||||||
rng?: Rng,
|
rng?: Rng,
|
||||||
|
nowMs?: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const openCount = (
|
const real = nowMs ?? Date.now();
|
||||||
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
|
const [planeRows, packageRows] = await Promise.all([
|
||||||
).length;
|
inst.db.select().from(schema.planes),
|
||||||
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
|
inst.db.select().from(schema.packages),
|
||||||
if (needed <= 0) return 0;
|
]);
|
||||||
|
|
||||||
|
const pool = poolAirports(planeRows);
|
||||||
|
if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||||||
@@ -335,82 +387,66 @@ export async function spawnOpenPackages(
|
|||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!account) throw new Error('account row missing');
|
if (!account) throw new Error('account row missing');
|
||||||
|
|
||||||
for (let i = 0; i < needed; i++) {
|
|
||||||
await inst.db.insert(schema.packages).values(
|
|
||||||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
|
|
||||||
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
|
|
||||||
// package originating there — so work is always available where the fleet sits
|
|
||||||
// (including onboarding at EDDF). One package per uncovered airport, generated
|
|
||||||
// with generatePackage and origin forced to that airport. Runs after
|
|
||||||
// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage
|
|
||||||
// too; and after deliverPackages, so a plane that just landed gets work spawned
|
|
||||||
// at its new location within the same tick. Returns the number created.
|
|
||||||
export async function spawnCoveragePackages(
|
|
||||||
inst: GameDb,
|
|
||||||
clockMs: number,
|
|
||||||
rng?: Rng,
|
|
||||||
): Promise<number> {
|
|
||||||
const [planeRows, openRows] = await Promise.all([
|
|
||||||
inst.db.select().from(schema.planes),
|
|
||||||
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
|
||||||
// they can't take new work until they land).
|
|
||||||
const groundedIcas = [
|
|
||||||
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
|
|
||||||
];
|
|
||||||
if (groundedIcas.length === 0) return 0;
|
|
||||||
|
|
||||||
const covered = new Set(openRows.map((c) => c.originIcao));
|
|
||||||
const missing = groundedIcas.filter((icao) => !covered.has(icao));
|
|
||||||
if (missing.length === 0) return 0;
|
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
|
||||||
icao: f.properties.icao,
|
|
||||||
name: f.properties.name,
|
|
||||||
lat: f.properties.lat,
|
|
||||||
lon: f.properties.lon,
|
|
||||||
}));
|
|
||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
|
||||||
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
|
||||||
if (!account) throw new Error('account row missing');
|
|
||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const icao of missing) {
|
for (const originIcao of pool) {
|
||||||
const oi = airports.findIndex((a) => a.icao === icao);
|
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
|
||||||
if (oi < 0) continue; // defensive — the dataset is the source of all ICACs
|
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
|
||||||
// v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs —
|
const openByBand = [0, 0, 0, 0];
|
||||||
// one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged.
|
let blockedUntil: number | null = null;
|
||||||
const di = pickDestWithinCap(airports, oi, rand);
|
for (const p of packageRows) {
|
||||||
await inst.db.insert(schema.packages).values(
|
if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue;
|
||||||
generatePackage({
|
const b = poolBandOf(p.weightKg);
|
||||||
id: randomUUID(),
|
if (p.status === 'open') openByBand[b]++;
|
||||||
airports,
|
else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) {
|
||||||
rng: rand,
|
// ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job
|
||||||
nowMs: clockMs,
|
// refills too. Per-band tracking is a trivial change if the board feels sluggish.
|
||||||
originIcao: icao,
|
blockedUntil = p.slotFreeAtRealMs;
|
||||||
...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior
|
}
|
||||||
reputation: account.reputation,
|
}
|
||||||
}),
|
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
|
||||||
);
|
const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS;
|
||||||
n++;
|
while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) {
|
||||||
|
await inst.db.insert(schema.packages).values(
|
||||||
|
generatePoolPackage({
|
||||||
|
airports,
|
||||||
|
originIcao,
|
||||||
|
bandIdx,
|
||||||
|
rng: rand,
|
||||||
|
clockMs,
|
||||||
|
id: randomUUID(),
|
||||||
|
createdRealMs: real,
|
||||||
|
reputation: account.reputation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
openByBand[bandIdx]++;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||||||
|
|
||||||
|
// ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE
|
||||||
|
// subscribers (/api/events). ISS-133 (v0.59.0): frames carry the sseFrame projection — terminal
|
||||||
|
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
||||||
|
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||||
|
// other clients too.
|
||||||
|
async function broadcastState(): Promise<void> {
|
||||||
|
try {
|
||||||
|
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('flight: state broadcast failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function tickNow(): Promise<schema.SimStateRow> {
|
export function tickNow(): Promise<schema.SimStateRow> {
|
||||||
return withLock(async () => tickOnce(await getGameDb()));
|
return withLock(async () => {
|
||||||
|
const row = await tickOnce(await getGameDb());
|
||||||
|
await broadcastState();
|
||||||
|
return row;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
|
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { broadcast, subscribe } from './sse';
|
||||||
|
|
||||||
|
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
|
||||||
|
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
||||||
|
// each test only asserts on its own arrays.
|
||||||
|
|
||||||
|
describe('lib/sse', () => {
|
||||||
|
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
|
||||||
|
const a: string[] = [];
|
||||||
|
const b: string[] = [];
|
||||||
|
const unsubA = subscribe((t) => a.push(t));
|
||||||
|
const unsubB = subscribe((t) => b.push(t));
|
||||||
|
|
||||||
|
broadcast('x');
|
||||||
|
expect(a).toEqual(['x']);
|
||||||
|
expect(b).toEqual(['x']);
|
||||||
|
|
||||||
|
unsubA();
|
||||||
|
broadcast('y');
|
||||||
|
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||||
|
expect(b).toEqual(['x', 'y']);
|
||||||
|
unsubB();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a throwing subscriber does not break the others', () => {
|
||||||
|
const got: string[] = [];
|
||||||
|
subscribe(() => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
const unsub = subscribe((t) => got.push(t));
|
||||||
|
broadcast('z');
|
||||||
|
expect(got).toEqual(['z']);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribe is idempotent', () => {
|
||||||
|
const got: string[] = [];
|
||||||
|
const unsub = subscribe((t) => got.push(t));
|
||||||
|
unsub();
|
||||||
|
unsub(); // second call must not throw
|
||||||
|
broadcast('w');
|
||||||
|
expect(got).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
||||||
|
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
||||||
|
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
|
||||||
|
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
||||||
|
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
||||||
|
// give each its own empty set and no push would ever be delivered.
|
||||||
|
|
||||||
|
type Push = (text: string) => void;
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
||||||
|
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
||||||
|
|
||||||
|
export function subscribe(fn: Push): () => void {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call each subscriber safely — one dead stream must not take the others down.
|
||||||
|
export function broadcast(text: string): void {
|
||||||
|
for (const fn of [...subscribers]) {
|
||||||
|
try {
|
||||||
|
fn(text);
|
||||||
|
} catch {
|
||||||
|
// subscriber's stream is gone; its own cleanup unsubscribes it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-6
@@ -35,8 +35,14 @@ export async function fetchState(): Promise<StateSnapshot> {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
|
// The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane
|
||||||
// so every pane renders from the same snapshot instead of running its own poll loops.
|
// renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary
|
||||||
|
// transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an
|
||||||
|
// automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh
|
||||||
|
// events flow. EventSource reconnects natively, so there is no custom reconnect logic here.
|
||||||
|
const SSE_URL = '/api/events';
|
||||||
|
const SSE_SILENCE_MS = 5_000;
|
||||||
|
|
||||||
export function useGameState(intervalMs = 1000): GameStateResult {
|
export function useGameState(intervalMs = 1000): GameStateResult {
|
||||||
const [state, setState] = useState<StateSnapshot | null>(null);
|
const [state, setState] = useState<StateSnapshot | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -70,10 +76,54 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh(); // fetch immediately on mount, don't wait for the first interval tick
|
let pollId: ReturnType<typeof setInterval> | null = null;
|
||||||
const id = setInterval(refresh, intervalMs);
|
let silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
return () => clearInterval(id);
|
let closed = false;
|
||||||
}, [intervalMs, refresh]);
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollId !== null) clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
};
|
||||||
|
// Fallback loop: immediate catch-up fetch, then the old interval.
|
||||||
|
const startPolling = () => {
|
||||||
|
if (closed || pollId !== null) return;
|
||||||
|
refresh();
|
||||||
|
pollId = setInterval(refresh, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
try {
|
||||||
|
source = new EventSource(SSE_URL);
|
||||||
|
} catch {
|
||||||
|
source = null; // no EventSource support — pure polling below
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== null) {
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
applySnapshot(JSON.parse(event.data));
|
||||||
|
} catch {
|
||||||
|
// malformed event — the next one overwrites it anyway
|
||||||
|
}
|
||||||
|
stopPolling(); // fresh push flow → polling no longer needed
|
||||||
|
if (silenceTimer !== null) clearTimeout(silenceTimer);
|
||||||
|
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // re-arm on EVERY message: a stream that goes silent after the first event (half-open connection — no onerror fires) must fall back to polling again, or state freezes until reload
|
||||||
|
};
|
||||||
|
source.onerror = () => startPolling(); // stream trouble → poll until pushes resume
|
||||||
|
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll
|
||||||
|
} else {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
source?.close();
|
||||||
|
stopPolling();
|
||||||
|
if (silenceTimer !== null) clearTimeout(silenceTimer);
|
||||||
|
};
|
||||||
|
}, [intervalMs, refresh, applySnapshot]);
|
||||||
|
|
||||||
return { state, loading, error, refresh, applySnapshot };
|
return { state, loading, error, refresh, applySnapshot };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { WORLD_TIME_SCALE } from './worldClock';
|
|||||||
|
|
||||||
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
|
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
|
||||||
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
|
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
|
||||||
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
|
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE while something is
|
||||||
|
// flying or in repair, and stays frozen at the anchor value while fully grounded (#135).
|
||||||
|
|
||||||
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
||||||
clockMs: 1_750_000_000_000,
|
clockMs: 1_750_000_000_000,
|
||||||
@@ -24,10 +25,10 @@ describe('advanceSimClock', () => {
|
|||||||
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
|
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is frozen while idle regardless of elapsed time', () => {
|
it('stays exactly at the anchor value while grounded (frozen, #135)', () => {
|
||||||
const a = anchor({ advancing: false });
|
const a = anchor({ advancing: false });
|
||||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
|
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — the anchor value
|
||||||
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
expect(advanceSimClock(a, 1_001_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
||||||
|
|||||||
+7
-7
@@ -9,8 +9,8 @@ import { WORLD_TIME_SCALE } from './worldClock';
|
|||||||
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
||||||
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
||||||
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
||||||
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
|
// fixed WORLD_TIME_SCALE; #135 (v0.62.0): while nothing is happening it stays FROZEN at the anchor
|
||||||
// server's idle-pause instead of a player-controlled pause.
|
// value — mirroring the server's freeze, so polls re-anchor without any jump.
|
||||||
|
|
||||||
export interface SimClockAnchor {
|
export interface SimClockAnchor {
|
||||||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||||||
@@ -22,10 +22,10 @@ export interface SimClockAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||||||
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
|
// real-time × WORLD_TIME_SCALE; while fully grounded it stays frozen at the anchor value (#135).
|
||||||
// the anchor value instead of going backwards. Unit-testable in node.
|
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
||||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||||
if (!anchor.advancing) return anchor.clockMs;
|
if (!anchor.advancing) return anchor.clockMs; // frozen while everything is grounded (#135)
|
||||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
|||||||
// (useGameState), so this runs once per poll cycle as well.
|
// (useGameState), so this runs once per poll cycle as well.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
|
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
|
||||||
// flying or in repair. One line on purpose — keep it in sync with that function.
|
// is flying or in repair, frozen otherwise (#135). One line on purpose — keep it in sync.
|
||||||
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||||||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
|
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
|
||||||
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
|
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
|
||||||
export const WORLD_TIME_SCALE = 24;
|
export const WORLD_TIME_SCALE = 24;
|
||||||
|
|
||||||
|
// #135 (v0.62.0): a fully grounded fleet FREEZES the world clock — shouldAdvanceTime
|
||||||
|
// (lib/sim/engine.ts) gates every tick and advanceSimClock (lib/useSimClock.ts) holds its anchor
|
||||||
|
// value, so polls re-anchor without any jump. This replaces the v0.56.0 idle slowdown
|
||||||
|
// (IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24): final semantics are ISS-117's — frozen when
|
||||||
|
// everything is grounded, full rate otherwise.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.53.0",
|
"version": "0.73.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
//
|
//
|
||||||
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
|
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
|
||||||
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
|
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
|
||||||
|
// v0.68.0 (ISS-144): additionally keeps a sparse set of "special" remote airports — non-large
|
||||||
|
// fixed-wing rows with no large airport within 1000 km, greedily thinned to one per ~1000 km
|
||||||
|
// region (scheduled service + larger types preferred) so Antarctica, the high Arctic, Alaska,
|
||||||
|
// northern Canada and far islands are represented without over-filling empty regions.
|
||||||
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
|
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
|
||||||
//
|
//
|
||||||
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
|
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
|
||||||
@@ -14,6 +18,22 @@ import path from 'node:path';
|
|||||||
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
|
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
|
||||||
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
|
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
|
||||||
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
|
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
|
||||||
|
// v0.61.0 (ISS-097): mirrors REMOTE_RADIUS_KM in lib/airports.ts — "no other airport within this
|
||||||
|
// radius" is what makes a small airport worth keeping (Antarctica, high Arctic, far islands).
|
||||||
|
const REMOTE_RADIUS_KM = 1000;
|
||||||
|
|
||||||
|
// Same fixed-wing type set as INCLUDED_TYPES in lib/airports.ts (standalone copy — the script must
|
||||||
|
// stay dependency-free): all fixed-wing airports plus seaplane bases.
|
||||||
|
const FIXED_WING_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
||||||
|
|
||||||
|
// Standalone copy of isRemote in lib/airports.ts, for CSV rows: true when `row` has no member of
|
||||||
|
// `others` within REMOTE_RADIUS_KM. Callers pass disjoint sets (the row itself is not in `others`).
|
||||||
|
function isRemoteRow(row, others) {
|
||||||
|
for (const o of others) {
|
||||||
|
if (haversineKm(row, o) <= REMOTE_RADIUS_KM) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
|
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
|
||||||
function parseCsv(text) {
|
function parseCsv(text) {
|
||||||
@@ -86,26 +106,33 @@ const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"
|
|||||||
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
|
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
|
||||||
|
|
||||||
const allRows = parseCsv(text);
|
const allRows = parseCsv(text);
|
||||||
const large = allRows.filter((r) => r.type === 'large_airport');
|
// v0.61.0 (ISS-097): curation runs over ALL fixed-wing rows, not just large_airport — the remote
|
||||||
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
|
// pass below needs the full pool to test against.
|
||||||
|
const fixed = allRows.filter((r) => FIXED_WING_TYPES.has(r.type));
|
||||||
|
const large = fixed.filter((r) => r.type === 'large_airport');
|
||||||
|
console.log(`downloaded ${allRows.length} rows; ${fixed.length} are fixed-wing, ${large.length} of them large_airport`);
|
||||||
|
|
||||||
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
|
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
|
||||||
// radius, otherwise it starts a new one. Deterministic — same input, same output.
|
// radius, otherwise it starts a new one. Deterministic — same input, same output. Returns the
|
||||||
const clusters = []; // { anchorIdx (into `large`), members }
|
// "biggest" member (one airport per cluster) plus the clusters themselves for reporting.
|
||||||
for (let i = 0; i < large.length; i++) {
|
function clusterWinners(rows) {
|
||||||
let joined = false;
|
const clusters = []; // { anchorIdx (into rows), members }
|
||||||
for (const c of clusters) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
let joined = false;
|
||||||
|
for (const c of clusters) {
|
||||||
|
if (haversineKm(rows[i], rows[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
||||||
|
}
|
||||||
|
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
||||||
}
|
}
|
||||||
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
const winners = clusters.map((c) => biggestOf(c.members.map((i) => rows[i])));
|
||||||
|
return { winners, clusters };
|
||||||
}
|
}
|
||||||
|
|
||||||
const kept = [];
|
const { winners: kept, clusters: largeClusters } = clusterWinners(large);
|
||||||
const multiClusters = [];
|
const multiClusters = [];
|
||||||
for (const c of clusters) {
|
for (const c of largeClusters) {
|
||||||
const winner = biggestOf(c.members.map((i) => large[i]));
|
|
||||||
kept.push(winner);
|
|
||||||
if (c.members.length > 1) {
|
if (c.members.length > 1) {
|
||||||
|
const winner = biggestOf(c.members.map((i) => large[i]));
|
||||||
multiClusters.push({
|
multiClusters.push({
|
||||||
city: winner.municipality || winner.name,
|
city: winner.municipality || winner.name,
|
||||||
keptIcao: winner.icao_code || winner.ident,
|
keptIcao: winner.icao_code || winner.ident,
|
||||||
@@ -114,17 +141,39 @@ for (const c of clusters) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.68.0 (ISS-144): "special" remote airports — one per ~REMOTE_RADIUS_KM region. Candidates are
|
||||||
|
// the non-large fixed-wing rows with no large airport within REMOTE_RADIUS_KM (unchanged), sorted
|
||||||
|
// by priority (scheduled service, then type rank medium < small < seaplane, then numeric id) and
|
||||||
|
// accepted greedily: a candidate joins only if it is at least REMOTE_RADIUS_KM from EVERY already
|
||||||
|
// accepted special — no special airport within 1000 km of another. One per ~1000 km region, so
|
||||||
|
// central Australia / Canada edges get a handful of well-chosen strips instead of dozens; this
|
||||||
|
// subsumes the old 40 km remote clustering (deleted).
|
||||||
|
const TYPE_RANK = { medium_airport: 0, small_airport: 1, seaplane_base: 2 }; // legacy 'airport' ranks last
|
||||||
|
const candidates = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.scheduled_service === 'yes') - Number(a.scheduled_service === 'yes') ||
|
||||||
|
(TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3) ||
|
||||||
|
Number(a.id) - Number(b.id),
|
||||||
|
);
|
||||||
|
const remoteKept = [];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (remoteKept.every((k) => haversineKm(c, k) >= REMOTE_RADIUS_KM)) remoteKept.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Guards (fail loud before writing anything) ---
|
// --- Guards (fail loud before writing anything) ---
|
||||||
|
const finalRows = [...kept, ...remoteKept];
|
||||||
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
|
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
|
||||||
const keys = new Set(kept.map(key));
|
const keys = new Set(finalRows.map(key));
|
||||||
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
if (keys.size !== finalRows.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
||||||
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
|
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
|
||||||
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
|
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
|
||||||
}
|
}
|
||||||
if (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); process.exit(1); }
|
if (remoteKept.length === 0) { console.error('no remote airports found in the dump — curation rule broken, aborting'); process.exit(1); }
|
||||||
|
if (finalRows.length < 300 || finalRows.length > 2500) { console.error(`unexpected row count ${finalRows.length}`); process.exit(1); }
|
||||||
|
|
||||||
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...finalRows.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
||||||
|
|
||||||
console.log(`wrote ${OUT_FILE}: ${kept.length} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
|
console.log(`wrote ${OUT_FILE}: ${kept.length} large + ${remoteKept.length} remote = ${finalRows.length} rows (${large.length - kept.length} large dropped into ${multiClusters.length} multi-airport clusters)`);
|
||||||
console.log('sample merged cities (kept ← dropped):');
|
console.log('sample merged cities (kept ← dropped):');
|
||||||
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);
|
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user