Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff432da97e | ||
|
|
37bdf902cc | ||
|
|
b683c4e546 | ||
|
|
82e4c64206 | ||
|
|
db5ad639fb | ||
|
|
7f28666f2e | ||
|
|
5093fba82e | ||
|
|
cde4334946 | ||
|
|
127d8f97f0 | ||
|
|
479c5448f7 | ||
|
|
d2b799e572 | ||
|
|
664906b8ce | ||
|
|
b60006a949 | ||
|
|
fe84d9a0bb | ||
|
|
43c4bb97a9 | ||
|
|
b04e504263 | ||
|
|
d87d848ca2 | ||
|
|
eae680c002 | ||
|
|
c3fa5bf613 | ||
|
|
fe60bd0482 | ||
|
|
db77f03ce1 | ||
|
|
d00a012c4b | ||
|
|
306f81aec6 | ||
|
|
ec26f9f366 | ||
|
|
ba2c36a3f8 | ||
|
|
81ba642ec5 | ||
|
|
b0f1bd8b67 |
@@ -1,5 +1,72 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.12.0 — 2026-09-28 06:59
|
||||||
|
- **Takeoff & landing phases on the marker.** Every leg now has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the destination, on top of the pure cruise time (`lib/sim/movement.ts`): new `BASE_PHASE_MS`, `phaseFactor(weather)` ∈ [1, 2] (monotone over Clear < Cloudy < Rain < Fog — clear stays at 1×, Fog is exactly 2×), and `legPhases(distanceKm, speedKmh, originIcao, destIcao) → {takeoffMs, cruiseMs, landingMs, totalMs}` as the single composition. Takeoff scales on ORIGIN weather, landing on DESTINATION weather — both deterministic via `weatherFor(icao)`, so no stored state. The engine's arrival gate (`lib/sim/engine.ts`), the client's live position/fuel recompute (`lib/game/state.ts`) and the job expiry floor (`lib/game/seed.ts`) all route through it; `progressAt` is now piecewise — 0 while holding at the origin during takeoff, linear over cruise, 1 while holding at the destination during landing — so markers park at each gate instead of crawling along the whole route. The phases burn no fuel (pure time); the weighted-burn model is untouched.
|
||||||
|
- **Marker phase label.** `lib/mapData.ts` derives a phase label client-side from `departureSimTime + clockMs + legPhases` (all already in the snapshot) and the plane popup shows it next to the status — "flying · taking off" / "flying · cruising" / "flying · landing" (`components/Map.tsx`, new `data-testid="plane-phase"` sibling; `plane-status` keeps the raw status so existing assertions stay exact).
|
||||||
|
- **Tests.** Unit: `lib/sim/movement.test.ts` — pinned cruise duration unchanged, new suites for `phaseFactor` bounds/monotonicity (with `MAX_PHASE_FACTOR` pinned to the factor function like `MAX_WIND_BURN_FACTOR`), origin-vs-dest weather composition on a non-vacuous Rain/Cloudy pair, and piecewise `progressAt` holds; `lib/sim/engine.test.ts` — deleted the local `travelMs` mirror, all ~18 "exactly at arrival" fixture clocks now use the real `arrivalTimeFor(...)` composition (the wear-clamp second leg chains off leg 1's exact phase-inclusive arrival); `lib/game/seed.test.ts` expiry-floor mirrors updated to the phase-inclusive floor. E2E: new `e2e/phases.spec.ts` — dispatches the starter on EDDF→LEMD and, using the same `legPhases` boundaries as the engine, asserts the popup shows "taking off" while the plane holds at the origin gate, "landing" while it holds at the destination gate (scale dropped to 30× so each 600–1200 game-s window is observable), then "available" at Madrid; relocates home and restores the world rate.
|
||||||
|
|
||||||
|
## v1.11.0 — 2026-09-27 22:55
|
||||||
|
- **Do not show jobs for planes that are in air (ISS-159).** The right pane's open-package list now hides a row while no GROUNDED plane sits at the package's origin (`components/RightPane.tsx`): new exported `visibleOpenPackages(state)` replaces the bare `status === 'open'` filter — a job whose only claimant is still en route disappears from the list and reappears on landing. Payload-blocked rows (a grounded plane at the origin with no room) stay visible with their disabled Load button, exactly as before. Display-only: `poolAirports` and the spawner are untouched, so world state — jobs waiting at a flying plane's destination — is unchanged; this only stops rendering rows the player cannot dispatch yet. Known pre-existing gap (NOT fixed here): `loadEligiblePlanes` ignores plane ownership per the v1.4.0 shared-market design, so another player's grounded plane at an origin can make Load enabled for you → 403 on click.
|
||||||
|
- **Tests.** Unit: new `visibleOpenPackages` suite in `components/right-pane.test.ts` — grounded plane at origin → visible; only a flying plane there → hidden; grounded elsewhere + one flying to the origin → hidden; payload-blocked grounded plane at origin → still visible with Load disabled. E2E: new test in `e2e/right-pane.spec.ts` dispatches the only plane toward an airport, creates a job AT that destination (in the pool while airborne, so it reaches `/api/state`), and asserts no row renders until landing — pinning the client-side filter rather than the v0.65.0 server-side pool visibility.
|
||||||
|
|
||||||
|
## v1.10.0 — 2026-09-27 22:31
|
||||||
|
- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn).
|
||||||
|
- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button.
|
||||||
|
- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion).
|
||||||
|
|
||||||
|
## v1.9.0 — 2026-09-27 20:57
|
||||||
|
- **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is.
|
||||||
|
|
||||||
|
## v1.8.0 — 2026-09-27 20:13
|
||||||
|
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
|
||||||
|
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
|
||||||
|
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
|
||||||
|
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
|
||||||
|
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
|
||||||
|
|
||||||
|
## v1.7.0 — 2026-09-26 20:24
|
||||||
|
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
|
||||||
|
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
|
||||||
|
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
|
||||||
|
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
|
||||||
|
|
||||||
|
## v1.6.0 — 2026-09-26 19:35
|
||||||
|
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
|
||||||
|
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
|
||||||
|
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
|
||||||
|
|
||||||
|
## v1.5.0 — 2026-09-26 19:06
|
||||||
|
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
|
||||||
|
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
|
||||||
|
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
|
||||||
|
|
||||||
|
## v1.4.0 — 2026-09-26 18:35
|
||||||
|
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
|
||||||
|
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
|
||||||
|
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
|
||||||
|
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
|
||||||
|
|
||||||
|
## v1.3.0 — 2026-09-26 17:05
|
||||||
|
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
|
||||||
|
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
|
||||||
|
- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths.
|
||||||
|
|
||||||
|
## v1.2.0 — 2026-09-26 16:25
|
||||||
|
- **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had.
|
||||||
|
- **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane.
|
||||||
|
- **Mint hook default → kamilkrzywda.** The e2e specs play with the seeded world's planes by id (`plane-starter`), so the no-body mint now resolves to the existing local player that owns them after backfill — creating NO new rows; a custom username still creates a fresh account, now WITH onboarding. `e2e/auth.ts` and every spec are untouched: they keep working because the session they mint is the owner's.
|
||||||
|
- **OpenAPI.** The seven plane-targeted action endpoints document the new 403 (buy-plane has no plane target, so it gains none); path count unchanged at eighteen — `openapi.test.ts`'s per-endpoint error-code lists updated to match.
|
||||||
|
- **Tests.** Unit: new backfill migration test in `lib/db/client.test.ts` builds a pre-v1.2.0 file (two planes without owner_id) and verifies open() backfills both to `'local'`, does not re-seed, and is a no-op on second open (a post-migration owner change survives reopen); new ownership suite in `lib/game/actions.test.ts` — 403 for every action when the session player doesn't own the plane (both directions), per-owner last-plane guard against the world total, buyer stamped as owner with their own cash debited and local's untouched; register + mint route tests assert exactly one onboarding plane at EDDF owned by the new account. All 74 existing action call sites in `actions.test.ts` now pass `'local'`; no e2e spec changes needed (the minted session is the owner's).
|
||||||
|
|
||||||
|
## v1.1.0 — 2026-09-26 14:39
|
||||||
|
- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook.
|
||||||
|
- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip.
|
||||||
|
- **Sign-in screen replaces the shell until authenticated.** New `components/SignInScreen.tsx`: full-screen dark panel with two forms (sign in / create account) sharing one submit path; errors surface in a single `role=alert` line. `app/page.tsx` gates on it: stored session → `/api/me` check on mount (stale id cleared, network error falls through to sign-in), then the game shell mounts only when authenticated — so an unauthenticated browser never polls `/api/state`. New client module `lib/authClient.ts` owns the localStorage key (`flight.session`) and builds the `X-Flight-Session` header; every app fetch (state poll, SSE URL, action POSTs, fly/queue in Map) now carries it. Admin panel logout drops back to the sign-in screen via a new `onLogout` prop (page → Map → AdminPanel).
|
||||||
|
- **E2E session hook + spec updates.** New test-only `POST /api/test/mint-player-session` (FLIGHT_TEST_HOOKS=1, 404 elsewhere — like its `/api/test/*` siblings): creates the users+players rows for a username if missing and mints a session, idempotent across container restarts since the CI DB volume persists. `e2e/auth.ts` mints + applies it per test (context extraHTTPHeaders for spec-side requests, localStorage init script for the app's own fetches); every guarded-route spec authenticates in a beforeEach; events.spec uses the real client path (`?session=`); api.spec/openapi tests track the new eighteenth endpoint. Admin spec reworked: unauthenticated visitors see the sign-in screen (no shell), wrong credentials error there, and logout returns to it.
|
||||||
|
- **Tests.** Unit: new `lib/auth.test.ts` (hash round-trip; 401 on missing/unknown/expired session; admin→local player join; 500 on missing players row), register route tests (validation matrix, shared-id rows + starter economy + minted session, case-insensitive 409) and mint-hook tests (gate off → 404 even with CI's FLIGHT_TEST_HOOKS=1 env — stubbed explicitly, default-username creation, idempotent re-mint, bad usernames). All existing state/events/action route tests now mint a session for the seeded admin user in beforeAll. OpenAPI: `/api/register` documented (RegisterRequest schema; 200/400/409) — spec is now eighteen paths.
|
||||||
|
|
||||||
## v1.0.0 — 2026-09-24 14:20
|
## v1.0.0 — 2026-09-24 14:20
|
||||||
- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before.
|
- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before.
|
||||||
- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas.
|
- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas.
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { getPlayer } from '@/lib/db/localPlayer';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
import { GET } from './route';
|
import { GET } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
|
||||||
@@ -12,6 +18,8 @@ beforeAll(async () => {
|
|||||||
// vitest gives each test file its own module registry (fresh singleton).
|
// vitest gives each test file its own module registry (fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
|
||||||
|
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -20,8 +28,18 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/events', () => {
|
describe('GET /api/events', () => {
|
||||||
|
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
|
||||||
|
// param — this test exercises exactly that path (requireAuthSse).
|
||||||
|
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
|
||||||
|
|
||||||
|
it('rejects an unauthenticated request with 401', async () => {
|
||||||
|
const res = await GET(new Request('http://localhost/api/events'));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('streams SSE with the current snapshot as the first event', async () => {
|
it('streams SSE with the current snapshot as the first event', async () => {
|
||||||
const res = await GET();
|
const res = await GET(req());
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||||
expect(res.headers.get('cache-control')).toBe('no-cache');
|
expect(res.headers.get('cache-control')).toBe('no-cache');
|
||||||
@@ -34,8 +52,63 @@ describe('GET /api/events', () => {
|
|||||||
expect(text.startsWith('data: ')).toBe(true);
|
expect(text.startsWith('data: ')).toBe(true);
|
||||||
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||||
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
||||||
|
|
||||||
|
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
|
||||||
|
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
|
||||||
|
const planes = snap.planes as Array<Record<string, unknown>>;
|
||||||
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const key of ['ownerId', 'ownerName']) {
|
||||||
|
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
const pkgs = snap.packages as Array<Record<string, unknown>>;
|
||||||
|
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
|
||||||
} finally {
|
} finally {
|
||||||
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a distinct economy + their own session (rows minted like /api/register).
|
||||||
|
const idB = 'test-player-events-b';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
|
||||||
|
});
|
||||||
|
const sidB = await createSession(inst, idB);
|
||||||
|
const localCash = (await getPlayer(inst, 'local')).cash;
|
||||||
|
|
||||||
|
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
|
||||||
|
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
|
||||||
|
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
|
||||||
|
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
|
||||||
|
// Reads the next `data:` event off a stream and parses its snapshot.
|
||||||
|
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||||
|
const { value } = await reader.read();
|
||||||
|
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drain both connect frames (each already carries its own account row)…
|
||||||
|
const connectA = await readFrame(readerA);
|
||||||
|
const connectB = await readFrame(readerB);
|
||||||
|
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
|
||||||
|
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
|
||||||
|
|
||||||
|
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
|
||||||
|
await tickNow();
|
||||||
|
const pushA = await readFrame(readerA);
|
||||||
|
const pushB = await readFrame(readerB);
|
||||||
|
|
||||||
|
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
|
||||||
|
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
|
||||||
|
} finally {
|
||||||
|
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
|
await readerB.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import { requireAuthSse } from '@/lib/auth';
|
||||||
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
||||||
import { subscribe } from '@/lib/sse';
|
import { subscribe } from '@/lib/sse';
|
||||||
|
|
||||||
@@ -5,12 +6,22 @@ import { subscribe } from '@/lib/sse';
|
|||||||
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
|
// 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):
|
// 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
|
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
|
||||||
// pushed frames, so a connect never costs one full-history frame.
|
// pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
|
||||||
|
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
|
||||||
|
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
|
||||||
|
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
|
||||||
|
// be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
|
||||||
|
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
|
||||||
|
// player, pushing each frame only to its group; connect + pushed frames share one projection
|
||||||
|
// (getStateSnapshot + sseFrame), so they can never drift.
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const HEARTBEAT_MS = 15_000;
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: Request) {
|
||||||
|
const auth = await requireAuthSse(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -22,10 +33,12 @@ export async function GET() {
|
|||||||
start(controller) {
|
start(controller) {
|
||||||
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
// 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
|
// 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.
|
// for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
|
||||||
getStateSnapshot()
|
// every later push carry THIS session's player projection — subscribe keys on auth.player.id so
|
||||||
|
// the tick broadcast only sends this client frames projected for its own account row.
|
||||||
|
getStateSnapshot(auth.player.id)
|
||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
unsubscribe = subscribe((text) => {
|
unsubscribe = subscribe(auth.player.id, (text) => {
|
||||||
try {
|
try {
|
||||||
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
// 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).
|
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import path from 'node:path';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -21,7 +25,14 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
|
const post = (body: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/market/buy-plane', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/market/buy-plane', () => {
|
describe('POST /api/market/buy-plane', () => {
|
||||||
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = input.model;
|
const model = input.model;
|
||||||
return performAction((inst) => buyPlane(inst, model));
|
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -23,7 +27,14 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
|
const post = (body: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/market/sell-plane', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/market/sell-plane', () => {
|
describe('POST /api/market/sell-plane', () => {
|
||||||
it('rejects a missing planeId and unknown fields with 400', async () => {
|
it('rejects a missing planeId and unknown fields with 400', async () => {
|
||||||
@@ -49,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
|
|||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// A second, grounded plane so "flying" is the reason for rejection.
|
// A second, grounded plane so "flying" is the reason for rejection.
|
||||||
await db.insert(schema.planes).values({
|
await db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction((inst) => sellPlane(inst, planeId));
|
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,9 +26,11 @@ afterAll(async () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
|
const post = (id: string) =>
|
||||||
params: Promise.resolve({ id }),
|
POST(
|
||||||
});
|
new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
|
||||||
|
{ params: Promise.resolve({ id }) },
|
||||||
|
);
|
||||||
|
|
||||||
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
||||||
// the exact created/deadline times so tests can compute the penalty from the
|
// the exact created/deadline times so tests can compute the penalty from the
|
||||||
@@ -105,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
|
|||||||
// id; 409 wrong status or the plane is in flight. On success returns the full
|
// id; 409 wrong status or the plane is in flight. On success returns the full
|
||||||
// state snapshot.
|
// state snapshot.
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
return performAction((inst) => abandonPackage(inst, id));
|
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,9 +26,14 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const post = (id: string, body: unknown) =>
|
const post = (id: string, body: unknown) =>
|
||||||
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
|
POST(
|
||||||
params: Promise.resolve({ id }),
|
new Request(`http://localhost/api/packages/${id}/load`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id }) },
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/packages/:id/load', () => {
|
describe('POST /api/packages/:id/load', () => {
|
||||||
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
||||||
@@ -102,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction((inst) => loadPackage(inst, id, planeId));
|
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession } from '@/lib/auth';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
|
||||||
|
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -22,9 +26,14 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const post = (planeId: string, body: unknown) =>
|
const post = (planeId: string, body: unknown) =>
|
||||||
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
|
POST(
|
||||||
params: Promise.resolve({ planeId }),
|
new Request(`http://localhost/api/planes/${planeId}/fly`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'X-Flight-Session': sessionId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ planeId }) },
|
||||||
|
);
|
||||||
|
|
||||||
describe('POST /api/planes/:planeId/fly', () => {
|
describe('POST /api/planes/:planeId/fly', () => {
|
||||||
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
||||||
@@ -93,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
const destIcao = input.destIcao;
|
||||||
return performAction((inst) => flyPlane(inst, planeId, destIcao));
|
return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const index = input.index as number;
|
const index = input.index as number;
|
||||||
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
|
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
const destIcao = input.destIcao;
|
||||||
return performAction((inst) => queueLeg(inst, planeId, destIcao));
|
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { performAction, repairPlane } from '@/lib/game/actions';
|
|||||||
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
|
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
|
||||||
// On success returns the full state snapshot.
|
// On success returns the full state snapshot.
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
const { planeId } = await ctx.params;
|
const { planeId } = await ctx.params;
|
||||||
return performAction((inst) => repairPlane(inst, planeId));
|
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { verifyPassword } from '@/lib/auth';
|
||||||
|
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB
|
||||||
|
// singleton points at a throwaway file, and every created user is verified through verifyPassword().
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'register.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/register', { method: 'POST', body: JSON.stringify(body) }));
|
||||||
|
|
||||||
|
describe('POST /api/register', () => {
|
||||||
|
it('rejects malformed bodies and bad credentials with 400', async () => {
|
||||||
|
for (const body of [
|
||||||
|
{}, // missing both fields
|
||||||
|
{ username: 'ab', password: 'longenough' }, // too short
|
||||||
|
{ username: 'a'.repeat(21), password: 'longenough' }, // too long
|
||||||
|
{ username: 'bad user!', password: 'longenough' }, // bad charset (space + !)
|
||||||
|
{ username: 'validuser', password: 'short' }, // password < 8 chars
|
||||||
|
{ username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field
|
||||||
|
]) {
|
||||||
|
const res = await post(body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => {
|
||||||
|
const res = await post({ username: 'NewUser42', password: 'correct-horse-1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
|
||||||
|
expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased
|
||||||
|
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0];
|
||||||
|
const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0];
|
||||||
|
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
|
||||||
|
expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true);
|
||||||
|
expect(String(playerRow[1])).toBe('local');
|
||||||
|
expect(Number(playerRow[2])).toBe(STARTING_CASH);
|
||||||
|
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
|
||||||
|
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
|
||||||
|
expect(planes).toHaveLength(1);
|
||||||
|
expect(String(planes[0][2])).toBe('EDDF');
|
||||||
|
|
||||||
|
// The minted session resolves to the new user.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe(String(userRow[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when the username is taken (case-insensitively)', async () => {
|
||||||
|
await post({ username: 'taken-user', password: 'correct-horse-1' });
|
||||||
|
const res = await post({ username: 'Taken-User', password: 'different-pass-2' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — POST /api/register
|
||||||
|
// body { username: string, password: string } (both required; unknown fields 400).
|
||||||
|
// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400.
|
||||||
|
// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same
|
||||||
|
// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact
|
||||||
|
// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a
|
||||||
|
// session immediately and returns { sessionId, user } like /api/login — no second round-trip.
|
||||||
|
|
||||||
|
const USERNAME_RE = /^[a-z0-9_-]{3,20}$/;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'username' && key !== 'password') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = typeof input.username === 'string' ? input.username.toLowerCase() : '';
|
||||||
|
if (!USERNAME_RE.test(username)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const password = input.password;
|
||||||
|
if (typeof password !== 'string' || password.length < 8) {
|
||||||
|
return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]);
|
||||||
|
if (taken[0]?.values?.length) {
|
||||||
|
return Response.json({ error: 'username is already taken' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// One UUID for both rows — the account and its player share an id; requireAuth still joins on username.
|
||||||
|
const id = randomUUID();
|
||||||
|
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
hashPassword(password),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id,
|
||||||
|
provider: 'local',
|
||||||
|
username,
|
||||||
|
cash: STARTING_CASH,
|
||||||
|
reputation: STARTING_REPUTATION,
|
||||||
|
});
|
||||||
|
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
|
||||||
|
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
|
||||||
|
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const starter = buildStarterPlane(
|
||||||
|
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
|
||||||
|
randomUUID(),
|
||||||
|
);
|
||||||
|
const { queue, ...starterCols } = starter;
|
||||||
|
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
|
||||||
|
await persist(inst);
|
||||||
|
|
||||||
|
const sessionId = await createSession(inst, id);
|
||||||
|
return Response.json({ sessionId, user: { username, admin: false } });
|
||||||
|
}
|
||||||
@@ -2,12 +2,17 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import { GET } from './route';
|
import { GET } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Point the app's DB singleton at a throwaway file so this test never
|
// Point the app's DB singleton at a throwaway file so this test never
|
||||||
@@ -15,8 +20,14 @@ beforeAll(async () => {
|
|||||||
// vitest gives each test file its own module registry (fresh singleton).
|
// vitest gives each test file its own module registry (fresh singleton).
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
||||||
|
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
|
||||||
|
// (no password needed: createSession only inserts into sessions; the guard joins players on
|
||||||
|
// username → the 'local' row).
|
||||||
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
@@ -24,7 +35,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe('GET /api/state', () => {
|
describe('GET /api/state', () => {
|
||||||
it('returns 200 with the full snapshot shape', async () => {
|
it('returns 200 with the full snapshot shape', async () => {
|
||||||
const res = await GET();
|
const res = await GET(req());
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get('content-type')).toContain('application/json');
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
|
||||||
@@ -35,6 +46,7 @@ describe('GET /api/state', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
@@ -48,8 +60,8 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const planes = body.planes as Array<Record<string, unknown>>;
|
const planes = body.planes as Array<Record<string, unknown>>;
|
||||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
||||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
||||||
}
|
}
|
||||||
expect(typeof planes[0].locationName).toBe('string');
|
expect(typeof planes[0].locationName).toBe('string');
|
||||||
// The seeded starter is grounded — no flight destination yet.
|
// The seeded starter is grounded — no flight destination yet.
|
||||||
@@ -58,8 +70,8 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const packages = body.packages as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(packages.length).toBeGreaterThan(0);
|
expect(packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
||||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||||
@@ -75,13 +87,13 @@ describe('GET /api/state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||||
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||||
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||||
expect((await b).account.cash).toBe((await a).account.cash);
|
expect((await b).account.cash).toBe((await a).account.cash);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
|
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
|
||||||
const body = ((await GET()).json()) as Promise<{
|
const body = ((await GET(req())).json()) as Promise<{
|
||||||
catalog: Array<Record<string, unknown>>;
|
catalog: Array<Record<string, unknown>>;
|
||||||
fuelPricePerL: number;
|
fuelPricePerL: number;
|
||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
@@ -104,4 +116,71 @@ describe('GET /api/state', () => {
|
|||||||
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
||||||
expect(snap.sellFraction).toBe(SELL_FRACTION);
|
expect(snap.sellFraction).toBe(SELL_FRACTION);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second account with a distinct economy — rows minted like /api/register does.
|
||||||
|
const id = 'test-player-b';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
|
||||||
|
});
|
||||||
|
const sid = await createSession(inst, id);
|
||||||
|
|
||||||
|
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { account: { cash: number; reputation: number } };
|
||||||
|
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
|
||||||
|
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a plane of their own — rows minted like /api/register does.
|
||||||
|
const id = 'test-player-c';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
|
||||||
|
});
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
|
||||||
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
|
||||||
|
const starter = body.planes.find((p) => p.id === 'plane-starter');
|
||||||
|
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
|
||||||
|
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
|
||||||
|
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
|
||||||
|
expect(foreign?.ownerId).toBe(id);
|
||||||
|
expect(foreign?.ownerName).toBe('e2e-owner-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
|
||||||
|
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const snapBefore = await before;
|
||||||
|
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
|
||||||
|
|
||||||
|
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
|
||||||
|
await inst.db.update(schema.packages)
|
||||||
|
.set({ status: 'loaded', planeId: 'plane-test-c' })
|
||||||
|
.where(eq(schema.packages.id, String(openPkg.id)));
|
||||||
|
|
||||||
|
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const after = await snap;
|
||||||
|
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
|
||||||
|
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
|
||||||
|
for (const pkg of after.packages) { // …and every unassigned package stays null.
|
||||||
|
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
export async function GET() {
|
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
|
||||||
|
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
|
||||||
|
// v1.4.0 (ISS-112): full shared-world snapshot — ALL planes with owner info, packages with
|
||||||
|
// carrierName; same projection as the SSE frames, so poll and push can never drift.
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await requireAuth(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
try {
|
try {
|
||||||
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
|
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
|
||||||
// read reflects current game time (expiry/finalize/spawn run inside the tick).
|
// read reflects current game time (expiry/finalize/spawn run inside the tick).
|
||||||
await tickNow();
|
await tickNow();
|
||||||
return Response.json(await getStateSnapshot());
|
return Response.json(await getStateSnapshot(auth.player.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load game state', err);
|
console.error('failed to load game state', err);
|
||||||
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
@@ -46,10 +46,12 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
|
||||||
|
// player the e2e suite plays via mint-player-session's default).
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
|
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(await getStateSnapshot());
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
} 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 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
import { getSharedWorldReputation } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||||
@@ -83,9 +83,9 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
|
// v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
|
||||||
// as engine spawns; never force a fixed value here.
|
// rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
|
||||||
const player = await getLocalPlayer(inst);
|
const reputation = await getSharedWorldReputation(inst);
|
||||||
const created = generatePackage({
|
const created = generatePackage({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
airports,
|
airports,
|
||||||
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
|
|||||||
weightKg: input.weightKg as number | undefined,
|
weightKg: input.weightKg as number | undefined,
|
||||||
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||||||
special: input.special === undefined ? null : (input.special as string | null),
|
special: input.special === undefined ? null : (input.special as string | null),
|
||||||
reputation: player.reputation,
|
reputation,
|
||||||
});
|
});
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
|
||||||
|
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
|
||||||
|
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
|
||||||
|
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
|
||||||
|
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
|
||||||
|
// same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/delete-plane
|
||||||
|
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
|
||||||
|
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
|
||||||
|
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'username') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = input.username;
|
||||||
|
if (typeof username !== 'string' || username.length === 0) {
|
||||||
|
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
|
||||||
|
let deleted = 0;
|
||||||
|
if (players[0]) {
|
||||||
|
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
|
||||||
|
for (const plane of owned) {
|
||||||
|
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
|
||||||
|
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
|
||||||
|
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
await persist(inst);
|
||||||
|
}
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set
|
||||||
|
// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body?: unknown) =>
|
||||||
|
POST(
|
||||||
|
new Request('http://localhost/api/test/mint-player-session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('POST /api/test/mint-player-session', () => {
|
||||||
|
it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '');
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string };
|
||||||
|
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// The session resolves to the EXISTING admin user — no new users/players rows are created.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe('admin-kamilkrzywda');
|
||||||
|
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
|
||||||
|
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const res = await post({ username: 'e2e-tester' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { sessionId: string };
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
|
||||||
|
expect(userRow.length).toBe(1);
|
||||||
|
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
|
||||||
|
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
|
||||||
|
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
|
||||||
|
expect(planes).toHaveLength(1);
|
||||||
|
expect(String(planes[0][2])).toBe('EDDF');
|
||||||
|
|
||||||
|
// The minted session resolves to the new user.
|
||||||
|
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||||
|
expect(String(sess[0])).toBe(String(userRow[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
|
||||||
|
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
|
||||||
|
|
||||||
|
const res = await post({ username: 'e2e-tester' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
|
||||||
|
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bad usernames and unknown fields with 400', async () => {
|
||||||
|
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||||||
|
for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) {
|
||||||
|
const res = await post(body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
|
||||||
|
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
|
||||||
|
// /api/test/admin-session). Creates the users + players rows if missing — idempotent across
|
||||||
|
// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every
|
||||||
|
// other deployment 404s here, like its /api/test/* siblings.
|
||||||
|
//
|
||||||
|
// POST /api/test/mint-player-session
|
||||||
|
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
|
||||||
|
// with the seeded world's planes by id, so the minted session must resolve to the local player
|
||||||
|
// that owns them after the owner_id backfill); a custom username creates a fresh test account
|
||||||
|
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
|
||||||
|
// Returns { sessionId }.
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = 'kamilkrzywda';
|
||||||
|
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
|
||||||
|
// server-side); a fixed value keeps the row debuggable from the admin panel.
|
||||||
|
const TEST_PASSWORD = 'e2e-mint-password-42';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'username') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let username = DEFAULT_USERNAME;
|
||||||
|
if (input.username !== undefined) {
|
||||||
|
if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
username = input.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inst = await getGameDb();
|
||||||
|
let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]);
|
||||||
|
let userId = res[0]?.values?.[0]?.[0];
|
||||||
|
if (typeof userId !== 'string') {
|
||||||
|
// New test account — one UUID for both rows, starter economy like a fresh world.
|
||||||
|
const id = randomUUID();
|
||||||
|
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
hashPassword(TEST_PASSWORD),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id,
|
||||||
|
provider: 'local',
|
||||||
|
username,
|
||||||
|
cash: STARTING_CASH,
|
||||||
|
reputation: STARTING_REPUTATION,
|
||||||
|
});
|
||||||
|
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const starter = buildStarterPlane(
|
||||||
|
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
|
||||||
|
randomUUID(),
|
||||||
|
);
|
||||||
|
const { queue, ...starterCols } = starter;
|
||||||
|
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
|
||||||
|
userId = id;
|
||||||
|
}
|
||||||
|
const sessionId = await createSession(inst, userId); // persists the rows + session together
|
||||||
|
return Response.json({ sessionId });
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
@@ -42,10 +42,12 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
|
||||||
|
// the e2e suite plays via mint-player-session's default).
|
||||||
await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
|
await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
|
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(await getStateSnapshot());
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
} 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 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
@@ -57,7 +58,8 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
|
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(await getStateSnapshot());
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
} catch (err) {
|
} 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 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
@@ -74,7 +75,8 @@ export async function POST(req: Request) {
|
|||||||
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
||||||
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(await getStateSnapshot());
|
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||||
|
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||||
} catch (err) {
|
} 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 });
|
||||||
|
|||||||
+106
@@ -244,6 +244,15 @@ body {
|
|||||||
color: #bae6fd;
|
color: #bae6fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v1.4.0 (ISS-112): owner / carrier badge — whose plane or cargo this is in the shared world. */
|
||||||
|
.owner-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
.fuel-bar {
|
.fuel-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -727,3 +736,100 @@ body {
|
|||||||
.admin-row-actions {
|
.admin-row-actions {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
|
||||||
|
.leaderboard {
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th,
|
||||||
|
.leaderboard td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
|
||||||
|
.sign-in {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.97);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 300px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in input {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in .pane-btn {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-divider {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|||||||
+55
-1
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import LeftPane from '@/components/LeftPane';
|
import LeftPane from '@/components/LeftPane';
|
||||||
import RightPane from '@/components/RightPane';
|
import RightPane from '@/components/RightPane';
|
||||||
|
import SignInScreen from '@/components/SignInScreen';
|
||||||
import ToastStack from '@/components/Toasts';
|
import ToastStack from '@/components/Toasts';
|
||||||
import type { HoverTarget } from '@/components/hoverTypes';
|
import type { HoverTarget } from '@/components/hoverTypes';
|
||||||
|
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
|
||||||
import { useGameState } from '@/lib/useGameState';
|
import { useGameState } from '@/lib/useGameState';
|
||||||
import { useToasts } from '@/lib/useToasts';
|
import { useToasts } from '@/lib/useToasts';
|
||||||
import { diffSnapshots } from '@/lib/deriveEvents';
|
import { diffSnapshots } from '@/lib/deriveEvents';
|
||||||
@@ -13,6 +15,58 @@ import { diffSnapshots } from '@/lib/deriveEvents';
|
|||||||
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
// v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either
|
||||||
|
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
||||||
|
// feed never polls an unauthenticated /api/state.
|
||||||
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
// v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map.
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
// Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must
|
||||||
|
// track whoever just signed in, not the user probed on mount (or before the last logout).
|
||||||
|
const [probeKey, setProbeKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = storedSession();
|
||||||
|
if (!sid) {
|
||||||
|
setAuthed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
||||||
|
else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158)
|
||||||
|
setAuthed(res.ok);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [probeKey]);
|
||||||
|
|
||||||
|
if (authed === null) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<p data-testid="auth-checking" className="sign-in-loading">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!authed) {
|
||||||
|
// v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed —
|
||||||
|
// isAdmin has to come from the user who just signed in.
|
||||||
|
return <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||||
|
return <AppShell onLogout={() => setAuthed(false)} isAdmin={isAdmin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) {
|
||||||
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
||||||
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
||||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||||
@@ -49,7 +103,7 @@ export default function Home() {
|
|||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
action response into the shared state, exactly like RightPane does */}
|
action response into the shared state, exactly like RightPane does */}
|
||||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
||||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
|
||||||
|
|
||||||
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
|
// 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
|
// 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.
|
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
|
||||||
|
|
||||||
const SESSION_STORAGE_KEY = 'flight.session';
|
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
username: string;
|
username: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
@@ -18,15 +17,7 @@ interface RowsData {
|
|||||||
rows: Array<Record<string, unknown>>;
|
rows: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSession(): string | null {
|
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(SESSION_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPanel() {
|
|
||||||
const [session, setSession] = useState<string | null>(null);
|
const [session, setSession] = useState<string | null>(null);
|
||||||
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
|
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
@@ -45,15 +36,12 @@ export default function AdminPanel() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const authHeaders = useCallback(
|
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
|
||||||
(s: string) => ({ 'X-Flight-Session': s }),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// On mount: a stored session decides between the grid and the login form.
|
// On mount: a stored session decides between the grid and the login form.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const stored = readSession();
|
const stored = storedSession();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
return;
|
return;
|
||||||
@@ -61,7 +49,7 @@ export default function AdminPanel() {
|
|||||||
fetch('/api/me', { headers: authHeaders(stored) })
|
fetch('/api/me', { headers: authHeaders(stored) })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok || cancelled) {
|
if (!res.ok || cancelled) {
|
||||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
clearStoredSession();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,7 +59,7 @@ export default function AdminPanel() {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
clearStoredSession();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -161,7 +149,7 @@ export default function AdminPanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as { sessionId: string; user: Me };
|
const body = (await res.json()) as { sessionId: string; user: Me };
|
||||||
window.localStorage.setItem(SESSION_STORAGE_KEY, body.sessionId);
|
storeSession(body.sessionId);
|
||||||
setSession(body.sessionId);
|
setSession(body.sessionId);
|
||||||
setMe(body.user);
|
setMe(body.user);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
@@ -170,7 +158,8 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
|
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
|
||||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
clearStoredSession();
|
||||||
|
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setMe(null);
|
setMe(null);
|
||||||
setTable(null);
|
setTable(null);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { formatCash } from '@/lib/format';
|
||||||
|
import type { PlayerSummary } from '@/lib/game/state';
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
|
||||||
|
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
|
||||||
|
// reputation desc; cash then username break ties deterministically so rows don't shuffle
|
||||||
|
// between snapshots when two players share a reputation.
|
||||||
|
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
|
||||||
|
const sorted = [...players].sort(
|
||||||
|
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="leaderboard">
|
||||||
|
<table data-testid="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Cash</th>
|
||||||
|
<th>Reputation</th>
|
||||||
|
<th>Planes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p) => (
|
||||||
|
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
|
||||||
|
<td>{p.username}</td>
|
||||||
|
<td>{formatCash(p.cash)}</td>
|
||||||
|
<td>{p.reputation}</td>
|
||||||
|
<td>{p.planeCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,6 +157,10 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
|
|||||||
>
|
>
|
||||||
<div className="plane-main">
|
<div className="plane-main">
|
||||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||||
|
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
|
||||||
|
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
|
||||||
|
{p.ownerName}
|
||||||
|
</span>
|
||||||
{p.status === 'flying' ? (
|
{p.status === 'flying' ? (
|
||||||
<>
|
<>
|
||||||
<span className="plane-status status-flying">in flight</span>
|
<span className="plane-status status-flying">in flight</span>
|
||||||
|
|||||||
+41
-5
@@ -13,6 +13,8 @@ import { weatherFor } from '../lib/weather';
|
|||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import AdminPanel from './AdminPanel';
|
import AdminPanel from './AdminPanel';
|
||||||
|
import Leaderboard from './Leaderboard';
|
||||||
|
import { clearStoredSession, sessionHeaders } from '../lib/authClient';
|
||||||
|
|
||||||
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
||||||
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
||||||
@@ -69,11 +71,16 @@ interface MapProps {
|
|||||||
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
|
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
|
||||||
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
|
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
|
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
|
||||||
|
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
||||||
|
onLogout?: () => void;
|
||||||
|
// v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button.
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
||||||
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||||
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
// /api/airports is fetched at most once per page life: every styledata event
|
// /api/airports is fetched at most once per page life: every styledata event
|
||||||
@@ -94,6 +101,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
||||||
// stored session on every open.
|
// stored session on every open.
|
||||||
const [adminOpen, setAdminOpen] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
|
||||||
|
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||||
|
|
||||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||||
@@ -295,7 +304,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||||
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
|
||||||
|
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
|
||||||
body: JSON.stringify({ destIcao: p.icao }),
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
@@ -316,12 +326,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
if (!f) return;
|
if (!f) return;
|
||||||
const p = f.properties as PlaneFeatureProps;
|
const p = f.properties as PlaneFeatureProps;
|
||||||
const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : '';
|
const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : '';
|
||||||
|
// v1.12.0: phase label next to the status — "flying · taking off" etc. plane-status keeps
|
||||||
|
// the raw status so existing assertions on it stay exact.
|
||||||
|
const phaseLine = p.phaseLabel ? `<span data-testid="plane-phase"> · ${p.phaseLabel}</span>` : '';
|
||||||
new maplibregl.Popup({ offset: 12 })
|
new maplibregl.Popup({ offset: 12 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
`<div data-testid="plane-popup">` +
|
`<div data-testid="plane-popup">` +
|
||||||
`<strong>${escapeHtml(p.modelLabel)}</strong><br/>` +
|
`<strong>${escapeHtml(p.modelLabel)}</strong><br/>` +
|
||||||
`<span data-testid="plane-status">${p.status}</span><br/>` +
|
`<span data-testid="plane-status">${p.status}</span>${phaseLine}<br/>` +
|
||||||
`fuel <span data-testid="plane-fuel">${p.fuelText}</span>` +
|
`fuel <span data-testid="plane-fuel">${p.fuelText}</span>` +
|
||||||
routeLine +
|
routeLine +
|
||||||
`</div>`,
|
`</div>`,
|
||||||
@@ -561,6 +574,14 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort
|
||||||
|
// server-side session kill, drop the stored id, tell the shell to show sign-in again.
|
||||||
|
const onTopBarLogout = async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined);
|
||||||
|
clearStoredSession();
|
||||||
|
onLogout?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
@@ -584,14 +605,29 @@ 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). */}
|
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
|
||||||
|
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||||
|
Leaderboard
|
||||||
|
</button>
|
||||||
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
|
||||||
|
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
|
||||||
|
{isAdmin && (
|
||||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
Admin
|
Admin
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
|
||||||
|
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||||
<AdminPanel />
|
<AdminPanel onLogout={onLogout} />
|
||||||
|
</Modal>
|
||||||
|
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
|
||||||
|
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
|
||||||
|
<Leaderboard players={state?.players ?? []} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.11.0 (ISS-159): the open packages the market list shows — hidden while no GROUNDED plane sits at
|
||||||
|
// their origin ("jobs for planes that are in air" disappear until landing). Payload-blocked rows stay:
|
||||||
|
// a grounded plane at the origin keeps the row visible with its disabled Load button.
|
||||||
|
export function visibleOpenPackages(state: StateSnapshot): PackageState[] {
|
||||||
|
return state.packages.filter(
|
||||||
|
(p) => p.status === 'open' && state.planes.some((pl) => pl.status === 'available' && pl.locationIcao === p.originIcao),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load controls for one open package: a single Load button when exactly one plane
|
// Load controls for one open package: a single Load button when exactly one plane
|
||||||
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||||
// several are, and a disabled button with the reason otherwise. Each row owns its
|
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||||
@@ -141,7 +150,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|
||||||
const clockMs = state.simState.clockMs;
|
const clockMs = state.simState.clockMs;
|
||||||
const open = state.packages.filter((p) => p.status === 'open');
|
const open = visibleOpenPackages(state);
|
||||||
|
|
||||||
const catalog = state.catalog;
|
const catalog = state.catalog;
|
||||||
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
|
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
|
||||||
@@ -209,6 +218,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
{formatSpecialLabel(pkg.special)}
|
{formatSpecialLabel(pkg.special)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
|
||||||
|
{pkg.carrierName && (
|
||||||
|
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
|
||||||
|
{pkg.carrierName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{eligible.length > 0 ? (
|
{eligible.length > 0 ? (
|
||||||
<LoadControls
|
<LoadControls
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { storeSession } from '@/lib/authClient';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is
|
||||||
|
// stored. Both forms POST the same shape ({username, password}); login mints a session for an
|
||||||
|
// existing account, register creates one first and then mints it. On success the sessionId goes
|
||||||
|
// to localStorage (via storeSession) and onAuthed() flips the shell back in.
|
||||||
|
|
||||||
|
interface SignInScreenProps {
|
||||||
|
onAuthed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignInScreen({ onAuthed }: SignInScreenProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent<HTMLFormElement>, path: '/api/login' | '/api/register') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: String(form.get('username') ?? ''),
|
||||||
|
password: String(form.get('password') ?? ''),
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { sessionId?: string; error?: string };
|
||||||
|
if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
storeSession(data.sessionId);
|
||||||
|
onAuthed();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sign-in">
|
||||||
|
<h1 data-testid="app-title">Flight Ops</h1>
|
||||||
|
{error !== null && (
|
||||||
|
<p data-testid="auth-error" role="alert" className="sign-in-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
|
||||||
|
<label htmlFor="sign-in-username">Username</label>
|
||||||
|
<input id="sign-in-username" data-testid="sign-in-username" name="username" autoComplete="username" required />
|
||||||
|
<label htmlFor="sign-in-password">Password</label>
|
||||||
|
<input id="sign-in-password" data-testid="sign-in-password" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="sign-in-divider">or</p>
|
||||||
|
|
||||||
|
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
|
||||||
|
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
|
||||||
|
<input id="register-username" name="username" autoComplete="off" required />
|
||||||
|
<label htmlFor="register-password">Password (at least 8 characters)</label>
|
||||||
|
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
|
||||||
|
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
|
import { cargoWeightFor, loadEligiblePlanes, visibleOpenPackages } from './RightPane';
|
||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
|
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
|
||||||
@@ -27,6 +27,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
repairUntilSimTime: null,
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
|
|||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes,
|
planes,
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
|
||||||
packages,
|
packages,
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
@@ -130,3 +134,39 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
|
|||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('visibleOpenPackages (market list row filter, ISS-159)', () => {
|
||||||
|
it('shows an open package when a grounded plane sits at its origin', () => {
|
||||||
|
const s = snap(
|
||||||
|
[plane({ id: 'at-origin' })],
|
||||||
|
[pkg({ id: 'x' }), pkg({ id: 'onboard', status: 'loaded', planeId: 'at-origin' })], // non-open statuses never listed
|
||||||
|
);
|
||||||
|
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides an open package when only a flying plane is at its origin (in air, not grounded)', () => {
|
||||||
|
const s = snap([plane({ id: 'en-route', status: 'flying', destIcao: 'LEMD' })], [pkg({ id: 'x' })]);
|
||||||
|
expect(visibleOpenPackages(s)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides an open package when the grounded plane is elsewhere and another flies to its origin', () => {
|
||||||
|
const s = snap(
|
||||||
|
[
|
||||||
|
plane({ id: 'grounded-elsewhere', locationIcao: 'LEMD' }),
|
||||||
|
plane({ id: 'en-route', status: 'flying', destIcao: 'EDDF' }), // flying TO the package's origin — not grounded there yet
|
||||||
|
],
|
||||||
|
[pkg({ id: 'x' })],
|
||||||
|
);
|
||||||
|
expect(visibleOpenPackages(s)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a payload-blocked row visible when a grounded plane at the origin has no room (disabled Load path preserved)', () => {
|
||||||
|
const packages = [
|
||||||
|
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 })), // 4 × 1200 = 4800 — full
|
||||||
|
pkg({ id: 'x' }),
|
||||||
|
];
|
||||||
|
const s = snap([plane({ id: 'full' })], packages);
|
||||||
|
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']); // row stays…
|
||||||
|
expect(loadEligiblePlanes(s, s.packages.find((p) => p.id === 'x')!)).toEqual([]); // …with Load disabled
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+11
-5
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
|
|||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { windBurnFactor } from '../lib/sim/movement';
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
||||||
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
||||||
// via the right pane → click its destination in the on-board list (left pane since
|
// via the right pane → click its destination in the on-board list (left pane since
|
||||||
@@ -15,6 +20,7 @@ import { windBurnFactor } from '../lib/sim/movement';
|
|||||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const 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)
|
||||||
|
|
||||||
@@ -329,7 +335,7 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||||
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
|
test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||||
@@ -345,12 +351,12 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||||
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
// window so scale 10_000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||||
// the cash/rep 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).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
let s = await getState(page);
|
let s = await getState(page);
|
||||||
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||||
s = await getState(page);
|
s = await getState(page);
|
||||||
@@ -382,9 +388,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
// Restore: top up + fly home at SCALE.
|
// Restore: top up + fly home at FAST (pure arrival wait).
|
||||||
await setFuelFull(page, 'plane-starter');
|
await setFuelFull(page, 'plane-starter');
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(homeRes.ok()).toBeTruthy();
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
|
|||||||
+24
-22
@@ -1,12 +1,13 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// v0.71.0 — Admin panel e2e: top-bar button → modal with login form; wrong credentials show an
|
// v0.71.0 — Admin panel e2e: top-bar button → modal; wrong credentials show an error; a session
|
||||||
// error; a session minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates
|
// minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates WITHOUT the real
|
||||||
// WITHOUT the real admin password ever appearing in test code; then the generic DB grid is
|
// admin password ever appearing in test code; then the generic DB grid is exercised end-to-end:
|
||||||
// exercised end-to-end: table list → edit + save a cell (verified by an independent re-fetch) →
|
// table list → edit + save a cell (verified by an independent re-fetch) → add a row → delete it →
|
||||||
// add a row → delete it → logout back to the login form.
|
// logout. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated
|
||||||
|
// visitors see IT instead of the shell, and logout drops back to it.
|
||||||
|
|
||||||
const SESSION_KEY = 'flight.session'; // must match components/AdminPanel.tsx
|
const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||||
|
|
||||||
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
|
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
|
||||||
const res = await page.request.post('/api/test/admin-session');
|
const res = await page.request.post('/api/test/admin-session');
|
||||||
@@ -14,31 +15,32 @@ async function mintAdminSession(page: import('@playwright/test').Page): Promise<
|
|||||||
return ((await res.json()) as { sessionId: string }).sessionId;
|
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
|
test('admin panel: sign-in gate, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const adminBtn = page.getByTestId('admin-btn');
|
|
||||||
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
// --- Open the modal: no stored session → login form.
|
// --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107).
|
||||||
await adminBtn.click();
|
const signIn = page.getByTestId('sign-in-form');
|
||||||
const dialog = page.locator('[role="dialog"]');
|
await expect(signIn).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(dialog).toBeVisible();
|
await expect(page.getByTestId('app-header')).toHaveCount(0);
|
||||||
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).
|
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
|
||||||
await page.getByTestId('admin-username').fill('kamilkrzywda');
|
await page.getByTestId('sign-in-username').fill('kamilkrzywda');
|
||||||
await page.getByTestId('admin-password').fill('wrong-password-on-purpose');
|
await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose');
|
||||||
await page.getByTestId('admin-login-submit').click();
|
await page.getByTestId('sign-in-submit').click();
|
||||||
await expect(page.getByTestId('admin-error')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// --- Authenticate via the test hook + localStorage, then reopen the panel.
|
// --- Authenticate via the test hook + localStorage, then reload into the shell.
|
||||||
const sessionId = await mintAdminSession(page);
|
const sessionId = await mintAdminSession(page);
|
||||||
// Both values must be passed as arguments — the function runs in the browser, where Node-side
|
// Both values must be passed as arguments — the function runs in the browser, where Node-side
|
||||||
// variables are not in scope.
|
// variables are not in scope.
|
||||||
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
|
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
|
||||||
|
// The independent re-fetch below goes through page.request → needs the header at context level.
|
||||||
|
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
const adminBtn = page.getByTestId('admin-btn');
|
||||||
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// --- Open the modal: stored session is valid → straight to the table list.
|
||||||
await adminBtn.click();
|
await adminBtn.click();
|
||||||
|
|
||||||
// --- Authenticated admin → table list including packages and users.
|
// --- Authenticated admin → table list including packages and users.
|
||||||
@@ -104,8 +106,8 @@ test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, lo
|
|||||||
await page.getByTestId(`admin-delete-${addedIdx}`).click();
|
await page.getByTestId(`admin-delete-${addedIdx}`).click();
|
||||||
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
|
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
|
||||||
|
|
||||||
// --- Logout returns to the login form.
|
// --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell).
|
||||||
await page.getByTestId('admin-logout').click();
|
await page.getByTestId('admin-logout').click();
|
||||||
await expect(page.getByTestId('admin-username')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(tables).toBeHidden();
|
await expect(tables).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,7 @@ 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 seventeen endpoints and the package.json version', async ({ page }) => {
|
test('GET /api/openapi serves the spec with all eighteen endpoints and the package.json version', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/openapi');
|
const res = await page.request.get('/api/openapi');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
expect(res.headers()['content-type']).toContain('application/json');
|
expect(res.headers()['content-type']).toContain('application/json');
|
||||||
@@ -38,6 +38,7 @@ test('GET /api/openapi serves the spec with all seventeen endpoints and the pack
|
|||||||
'/api/planes/{planeId}/queue',
|
'/api/planes/{planeId}/queue',
|
||||||
'/api/planes/{planeId}/queue/cancel',
|
'/api/planes/{planeId}/queue/cancel',
|
||||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
|
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
]);
|
]);
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — session helpers for the e2e suite. Every game route is now session-guarded, so
|
||||||
|
// each spec authenticates in a beforeEach: mint a player session server-side via the test hook
|
||||||
|
// (FLIGHT_TEST_HOOKS=1; no real credentials in test code), then apply it BOTH ways the client uses —
|
||||||
|
// localStorage for the app's own fetches (lib/authClient.ts reads it) and context extraHTTPHeaders
|
||||||
|
// so page.request.* calls in specs carry X-Flight-Session too.
|
||||||
|
|
||||||
|
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||||
|
|
||||||
|
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
|
||||||
|
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
|
||||||
|
// usual local player, exactly as before.
|
||||||
|
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
|
||||||
|
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
|
||||||
|
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||||
|
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySession(page: Page, sessionId: string): Promise<void> {
|
||||||
|
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
|
||||||
|
// Runs before every navigation's scripts — the app reads the session from localStorage on load.
|
||||||
|
await page.addInitScript(
|
||||||
|
([key, sid]) => window.localStorage.setItem(key, sid),
|
||||||
|
[SESSION_KEY, sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticate(page: Page): Promise<string> {
|
||||||
|
const sessionId = await mintPlayerSession(page);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
+8
-2
@@ -15,8 +15,14 @@ interface StateBody {
|
|||||||
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
|
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
|
||||||
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
|
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
|
||||||
|
|
||||||
test('GET /api/events streams state snapshots over SSE', async () => {
|
test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
|
||||||
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
|
// v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client
|
||||||
|
// passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path.
|
||||||
|
const mint = await request.post('/api/test/mint-player-session');
|
||||||
|
expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||||
|
const { sessionId } = (await mint.json()) as { sessionId: string };
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||||
expect(res.body).not.toBeNull();
|
expect(res.body).not.toBeNull();
|
||||||
|
|||||||
+28
-17
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
|
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
|
||||||
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
|
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
|
||||||
// open-package rows highlight both endpoint airports. The map exposes three top-most
|
// open-package rows highlight both endpoint airports. The map exposes three top-most
|
||||||
@@ -12,6 +17,7 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||||
@@ -511,7 +517,7 @@ test('hovering the Relocate section previews the planned route and target airpor
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -520,23 +526,26 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
|
||||||
|
// post-action snapshot already shows the plane flying — no poll race.
|
||||||
const pkg = await createPackage(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(load.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
const flyBody = (await fly.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
|
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
|
||||||
|
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
|
||||||
|
// test below).
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||||
const block = page.getByTestId('plane-plane-starter');
|
const block = page.getByTestId('plane-plane-starter');
|
||||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
|
||||||
// e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
|
|
||||||
// the route line stays drawn while frozen.
|
|
||||||
await setScale(page, 1);
|
|
||||||
|
|
||||||
await block.hover();
|
await block.hover();
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||||||
@@ -549,9 +558,10 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
message: 'waiting for the highlight to clear after mouseleave',
|
message: 'waiting for the highlight to clear after mouseleave',
|
||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
// Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
// stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
|
||||||
await setScale(page, SCALE);
|
// arrivals → FAST (~1 s each instead of ~10 s).
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -559,8 +569,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// Re-set SCALE for the return leg.
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
@@ -572,7 +582,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -646,7 +656,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
|||||||
// not DEST — it never delivered), then fly home.
|
// not DEST — it never delivered), then fly home.
|
||||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
expect(cancelRes.status()).toBe(200);
|
expect(cancelRes.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -657,8 +668,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
|||||||
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
// Re-set SCALE for the return leg.
|
// Re-set FAST for the return leg (~1 s instead of ~10 s).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test';
|
|||||||
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
||||||
import { REPAIR_COST } from '../lib/game/catalog';
|
import { REPAIR_COST } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||||
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||||
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||||
// deterministic generated weather), plane markers whose positions come from the
|
// deterministic generated weather), plane markers whose positions come from the
|
||||||
@@ -15,6 +20,9 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
|||||||
// the 6300 L tank, so a full-tank departure always makes it home.
|
// the 6300 L tank, so a full-tank departure always makes it home.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish
|
||||||
|
// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate.
|
||||||
|
const FAST = 50_000;
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
@@ -101,15 +109,15 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
// Load a package onto plane-starter and fly it from → to via the API, then fast-forward at FAST
|
||||||
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
// (pure arrival wait — no mid-leg observation) until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||||
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
const pkg = await createPackage(page, fromIcao, toIcao);
|
const pkg = await createPackage(page, fromIcao, toIcao);
|
||||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(load.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
@@ -199,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
|
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup
|
test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -210,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||||
expect(foundStarter).toBeDefined();
|
expect(foundStarter).toBeDefined();
|
||||||
if (foundStarter!.status === 'flying') {
|
if (foundStarter!.status === 'flying') {
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -343,8 +351,8 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// The popup's route line is the plane's own leg: origin name → destination name.
|
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||||
|
|
||||||
// Restore SCALE before step 5 — the landing wait needs a fast clock.
|
// Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
|
|
||||||
// 5) Let the flight finish, then fly home so the live instance stays in its
|
// 5) Let the flight finish, then fly home so the live instance stays in its
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||||
@@ -473,7 +481,7 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
|
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
|
test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -484,7 +492,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
|||||||
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain.
|
// 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);
|
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') {
|
if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
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
|
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
|
||||||
@@ -495,7 +503,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
|||||||
for (const next of await chainTo(page, 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
|
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);
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,9 +524,12 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
|||||||
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
|
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);
|
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')!)}`);
|
console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||||
|
// The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the
|
||||||
|
// >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST
|
||||||
|
// rate would let the dispatch tick itself consume most of the leg before this setScale lands.
|
||||||
|
await setScale(page, SCALE);
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
|
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)`);
|
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')!)}`);
|
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
|
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
|
||||||
@@ -586,16 +597,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
|||||||
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(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
|
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 {
|
} finally {
|
||||||
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical
|
// Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other
|
||||||
// state. The package was delivered on arrival (generous deadline).
|
// specs start from the canonical state. The package was delivered on arrival (generous deadline).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
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;
|
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||||
if (endLoc !== HUB) {
|
if (endLoc !== HUB) {
|
||||||
for (const next of await chainTo(page, endLoc, HUB)) {
|
for (const next of await chainTo(page, endLoc, HUB)) {
|
||||||
await setFuelFull(page, 'plane-starter');
|
await setFuelFull(page, 'plane-starter');
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
const APP_URL = '/';
|
const APP_URL = '/';
|
||||||
|
|
||||||
// Read at test time so the assertion tracks package.json automatically.
|
// Read at test time so the assertion tracks package.json automatically.
|
||||||
@@ -73,6 +78,17 @@ test('header shows the app version sourced from package.json', async ({ page })
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button
|
||||||
|
// (mirrors e2e/admin.spec.ts' logout assertion).
|
||||||
|
test('top bar Log out drops back to the sign-in screen', async ({ page }) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
const header = page.getByTestId('app-header');
|
||||||
|
await expect(header).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId('logout-btn').click();
|
||||||
|
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(header).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||||||
|
|
||||||
|
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||||||
|
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||||||
|
// player; player B is a second account minted through the same hook with a username override (the
|
||||||
|
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||||||
|
//
|
||||||
|
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||||||
|
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||||||
|
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||||||
|
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||||||
|
|
||||||
|
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
|
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||||||
|
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
locationIcao: string;
|
||||||
|
maxFuel: number;
|
||||||
|
queue: string[];
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
cash: number;
|
||||||
|
reputation: number;
|
||||||
|
planeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number };
|
||||||
|
planes: PlaneState[];
|
||||||
|
players: PlayerSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
|
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||||
|
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||||
|
if (q.length === 0) return;
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||||||
|
async function nearestToHub(page: Page): Promise<string> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
return fc.features
|
||||||
|
.filter((f) => f.properties.icao !== HUB)
|
||||||
|
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||||
|
.sort((x, y) => x.d - y.d)[0].icao;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||||||
|
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
return { ctx, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
await clearQueueViaApi(page, STARTER);
|
||||||
|
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||||||
|
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||||||
|
if (p.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight starter to land during cleanup',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (p.locationIcao !== HUB) {
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the starter to land back at the hub during cleanup',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||||||
|
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||||||
|
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for the fleet to be fully grounded',
|
||||||
|
})
|
||||||
|
.toBe('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||||||
|
|
||||||
|
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
try {
|
||||||
|
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||||||
|
await healStarterHome(page);
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
} finally {
|
||||||
|
if (bCtx) await bCtx.close();
|
||||||
|
bCtx = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||||||
|
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||||||
|
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
try {
|
||||||
|
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||||||
|
// Verify through the same surface the game uses — mint a local session just for this read.
|
||||||
|
const sessionId = await mintPlayerSession(page);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
const s = (await getState(page)) as StateBody;
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||||||
|
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||||||
|
|
||||||
|
const dest = await nearestToHub(page);
|
||||||
|
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||||||
|
await setFuelFull(page, STARTER);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||||||
|
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||||||
|
let prev = (await getState(page)).simState.clockMs;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||||||
|
const now = s.simState.clockMs;
|
||||||
|
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: "waiting for A's plane to land",
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
|
||||||
|
const frozen1 = await getState(page);
|
||||||
|
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||||||
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||||||
|
const frozen2 = await getState(page);
|
||||||
|
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
|
||||||
|
const sA = await getState(page); // A's (local session's) view of the shared world
|
||||||
|
const sB = await getState(b.page); // B's view — same world…
|
||||||
|
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||||||
|
|
||||||
|
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||||||
|
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.ownerId).toBe('local');
|
||||||
|
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||||||
|
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||||||
|
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||||||
|
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||||||
|
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||||||
|
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||||||
|
|
||||||
|
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||||||
|
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||||||
|
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyForeign.status()).toBe(403);
|
||||||
|
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||||||
|
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||||||
|
expect(sellForeign.status()).toBe(403);
|
||||||
|
|
||||||
|
// …and B cannot act on A's.
|
||||||
|
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyA.status()).toBe(403);
|
||||||
|
|
||||||
|
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||||||
|
// so nothing else can touch either economy between reads.
|
||||||
|
const cA = sA.account.cash;
|
||||||
|
const cB = sB.account.cash;
|
||||||
|
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||||||
|
expect(grant.status()).toBe(200);
|
||||||
|
const granted = ((await grant.json()) as StateBody).account.cash;
|
||||||
|
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||||||
|
const sBAfter = await getState(b.page);
|
||||||
|
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||||||
|
|
||||||
|
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||||||
|
for (const s of [sA, sBAfter]) {
|
||||||
|
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
||||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
import { haversineKm } from '../lib/geo';
|
||||||
|
import { legPhases } from '../lib/sim/movement';
|
||||||
|
import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
// v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and
|
||||||
|
// assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate)
|
||||||
|
// and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The
|
||||||
|
// phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork:
|
||||||
|
// each observation happens inside a window computed from departureSimTime + clockMs.
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
|
// Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long
|
||||||
|
// enough for the fit + click + popup pipeline, short enough to keep the test fast.
|
||||||
|
const OBS = 30;
|
||||||
|
// Fast-forward through the cruise between the two observations (pure arrival wait).
|
||||||
|
const MID = 1_000;
|
||||||
|
// Self-heal and final arrival waits — no mid-leg observation at this rate.
|
||||||
|
const FAST = 50_000;
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
simState: { clockMs: number };
|
||||||
|
planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForAirportCount(page: Page): Promise<void> {
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left
|
||||||
|
// it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second.
|
||||||
|
async function ensureGroundedAt(page: Page, icao: string): Promise<void> {
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
if (p.status === 'available' && p.locationIcao === icao) return 'ok';
|
||||||
|
if (p.status === 'available') {
|
||||||
|
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } });
|
||||||
|
return `flying home from ${p.locationIcao}`;
|
||||||
|
}
|
||||||
|
return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly)
|
||||||
|
}, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame both endpoints so the marker is rendered wherever it holds on the leg.
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state
|
||||||
|
// fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames —
|
||||||
|
// used right after dispatch, when the last rendered frame may predate it.
|
||||||
|
async function clickPlane(page: Page, flyingOnly: boolean): Promise<void> {
|
||||||
|
const pt = await page.evaluate(async ({ onlyFlying, starterId }: { onlyFlying: boolean; starterId: string }) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) return null;
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
for (;;) {
|
||||||
|
let feats: Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }> = [];
|
||||||
|
try {
|
||||||
|
feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] });
|
||||||
|
} catch {
|
||||||
|
// map not ready yet — keep polling
|
||||||
|
}
|
||||||
|
const f = feats.find((x) => x.properties?.id === starterId && (!onlyFlying || x.properties?.flying === true));
|
||||||
|
if (f && f.geometry) {
|
||||||
|
const projected = m.project(f.geometry.coordinates);
|
||||||
|
const rect = m.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + projected.x, y: rect.top + projected.y };
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) return null;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}, { onlyFlying: flyingOnly, starterId: STARTER });
|
||||||
|
expect(pt, 'plane marker not rendered').not.toBeNull();
|
||||||
|
|
||||||
|
// Popups are created per click and never auto-close — remove any stale ones so the testids below
|
||||||
|
// stay unique.
|
||||||
|
await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove()));
|
||||||
|
await page.mouse.click(pt!.x, pt!.y);
|
||||||
|
const popup = page.getByTestId('plane-popup');
|
||||||
|
await expect(popup).toBeVisible({ timeout: 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left.
|
||||||
|
await setScale(page, FAST);
|
||||||
|
await ensureGroundedAt(page, HUB);
|
||||||
|
|
||||||
|
// The exact phase boundaries for this leg — same composition the engine uses (v1.12.0).
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((x) => x.properties.icao === HUB)!;
|
||||||
|
const dest = fc.features.find((x) => x.properties.icao === DEST)!;
|
||||||
|
const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon);
|
||||||
|
const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST);
|
||||||
|
|
||||||
|
// --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. ---
|
||||||
|
await setScale(page, OBS);
|
||||||
|
const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } });
|
||||||
|
expect(fly.status()).toBe(200);
|
||||||
|
const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!;
|
||||||
|
|
||||||
|
await fitToAirports(page, [HUB, DEST]);
|
||||||
|
await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch
|
||||||
|
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||||
|
await expect(page.getByTestId('plane-phase')).toContainText('taking off');
|
||||||
|
|
||||||
|
// --- Landing: fast-forward through the cruise to just before the landing window, then drop back
|
||||||
|
// to OBS so the hold at the destination gate is observable. ---
|
||||||
|
const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs;
|
||||||
|
await setScale(page, MID); // fast-forward through takeoff hold + cruise (pure arrival wait)
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const body = await getState(page);
|
||||||
|
const p = body.planes.find((x) => x.id === STARTER)!;
|
||||||
|
if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
|
||||||
|
return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins
|
||||||
|
}, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true);
|
||||||
|
|
||||||
|
await setScale(page, OBS);
|
||||||
|
// Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and
|
||||||
|
// before arrival (landingMs ≥ BASE_PHASE_MS > the offset below).
|
||||||
|
const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const body = await getState(page);
|
||||||
|
if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
|
||||||
|
return body.simState.clockMs >= landingTarget;
|
||||||
|
}, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true);
|
||||||
|
|
||||||
|
await clickPlane(page, true);
|
||||||
|
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||||
|
await expect(page.getByTestId('plane-phase')).toContainText('landing');
|
||||||
|
|
||||||
|
// --- Arrival: fast-forward until the engine flips it to available at DEST. ---
|
||||||
|
await setScale(page, FAST);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true);
|
||||||
|
|
||||||
|
// Leave the onboarding state for the other specs: starter grounded back at HUB.
|
||||||
|
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.locationIcao === HUB;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true);
|
||||||
|
});
|
||||||
+31
-15
@@ -7,6 +7,11 @@ import { haversineKm, initialBearingDeg } from '../lib/geo';
|
|||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||||
// while flying, ISS-053), and
|
// while flying, ISS-053), and
|
||||||
@@ -15,6 +20,12 @@ import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/move
|
|||||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale —
|
||||||
|
// a lagged tick can only finish the leg sooner, never skip an observed state.
|
||||||
|
const FAST = 10_000;
|
||||||
|
// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's
|
||||||
|
// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain.
|
||||||
|
const CHAIN = 4_000;
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||||
@@ -280,14 +291,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||||
|
|
||||||
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||||
await setScale(page, SCALE);
|
// Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// Re-set SCALE for the return leg.
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
@@ -361,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
// to arrival, then fly home.
|
// to arrival, then fly home.
|
||||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
expect(cancelRes.status()).toBe(200);
|
expect(cancelRes.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
// Pure arrival waits — FAST is safe and ~10× quicker than SCALE here.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// Re-set SCALE for the return leg.
|
// Re-set FAST for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
@@ -452,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
|||||||
expect(flyRes.status()).toBe(200);
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||||||
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
// return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at
|
||||||
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
// CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, CHAIN);
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||||||
@@ -561,8 +574,10 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
|
|||||||
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
|
||||||
// Landing at DEST auto-chains QUEUE with the top-up…
|
// Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must
|
||||||
await setScale(page, SCALE);
|
// stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the
|
||||||
|
// snapshot window below is sized for this rate.
|
||||||
|
await setScale(page, 2_000);
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||||
@@ -572,9 +587,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…
|
||||||
// 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn;
|
// > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind
|
||||||
// a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
// load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
||||||
expect(fuel).toBeGreaterThan(1.3 * required2);
|
expect(fuel).toBeGreaterThan(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.
|
||||||
@@ -598,7 +613,8 @@ test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)
|
|||||||
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
expect(cancel.status()).toBe(200);
|
expect(cancel.status()).toBe(200);
|
||||||
|
|
||||||
await setScale(page, SCALE);
|
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||||
|
|||||||
+13
-8
@@ -1,6 +1,11 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
|
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
|
||||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||||
@@ -9,7 +14,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — cleanup legs land in ~1 s
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const 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. The v0.70.0 default view ([0,0] z2)
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
|
||||||
@@ -88,8 +93,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
let s = await getState(page);
|
let s = await getState(page);
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~2 s real time worst case at FAST).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
}
|
}
|
||||||
if (plane.status === 'flying') {
|
if (plane.status === 'flying') {
|
||||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
||||||
@@ -99,8 +104,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
// Make sure the world rate is back at FAST so the return leg fast-forwards.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
@@ -117,7 +122,7 @@ test.afterEach(async ({ page }) => {
|
|||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -149,7 +154,7 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one leg to LFRS
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -216,7 +221,7 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg, fast-forwarded in cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
|
|||||||
+46
-25
@@ -9,6 +9,11 @@ import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/mo
|
|||||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
|
||||||
import { STARTING_REPUTATION } from '../lib/game/seed';
|
import { STARTING_REPUTATION } from '../lib/game/seed';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||||
@@ -149,39 +154,35 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
|||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. The end
|
||||||
|
// state is verified by ONE poll on FRESH snapshots (v1.12.0): legs now include takeoff/landing holds,
|
||||||
|
// so a leftover leg can land at its destination after an earlier snapshot was taken — deciding from a
|
||||||
|
// stale pre-flight snapshot let tests load cargo onto a plane that wasn't actually at the hub (409).
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||||
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
// ISS-127-style transient: test hooks intermittently 500 under dind load — retry briefly instead of failing.
|
||||||
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
for (let attempt = 1; ; attempt++) {
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
const healthRes = await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } });
|
||||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
if (healthRes.status() === 200) break;
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
if (attempt >= 3) throw new Error(`/api/test/set-health returned ${healthRes.status()} after 3 attempts`);
|
||||||
await setScale(page, SCALE);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
if (starter.status === 'flying') {
|
// Fast-forward any leftover leg / repositioning flight (a few seconds real time worst case at 1000×).
|
||||||
await expect
|
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
|
||||||
timeout: 240_000,
|
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
}
|
|
||||||
if (starter.locationIcao !== HUB) {
|
|
||||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
|
|
||||||
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
if (p.status === 'available' && p.locationIcao === HUB) return 'ok';
|
||||||
|
if (p.status === 'available') {
|
||||||
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable leg
|
||||||
|
// when cash allows — no top-up retry needed. Fail loud with the server's reason otherwise.
|
||||||
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
if (flyHome.status() !== 200) throw new Error(`reposition home failed (${flyHome.status()}): ${await flyHome.text()}`);
|
||||||
await expect
|
return `flying home from ${p.locationIcao}`;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
return p.status; // flying or repairing — keep waiting (SCALE makes both finish quickly)
|
||||||
|
}, { timeout: 240_000, message: 'waiting for plane-starter to be grounded at the hub' }).toBe('ok');
|
||||||
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||||
const s1 = await getState(page);
|
const s1 = await getState(page);
|
||||||
for (const pkg of s1.packages) {
|
for (const pkg of s1.packages) {
|
||||||
@@ -265,6 +266,26 @@ test('open jobs only appear where a plane can take them — foreign-origin jobs
|
|||||||
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('jobs at an airport with no grounded plane stay hidden while a plane is still en route (ISS-159)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // starter grounded at EDDF
|
||||||
|
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
// Dispatch the only plane toward `nearest` — while airborne its DESTINATION joins the pool (v0.65.0), so a job there
|
||||||
|
// reaches /api/state, but no GROUNDED plane sits at that airport yet: ISS-159 hides it until landing.
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const pkg = await createPackage(page, nearest.icao, HUB);
|
||||||
|
// The server keeps the job (its origin is in the pool)…
|
||||||
|
await expect.poll(async () => (await getState(page)).packages.some((p) => p.id === pkg.id), { timeout: 15_000 }).toBe(true);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
// …but no row renders while the plane is still en route — no grounded plane at its origin. No landing wait needed;
|
||||||
|
// the next test's flyHomeViaApi self-heal finishes this leg.
|
||||||
|
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||||
test.setTimeout(240_000); // one short flight at scale 1000 (seconds of real time) plus UI steps
|
test.setTimeout(240_000); // one short flight at scale 1000 (seconds of real time) plus UI steps
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
// pool, at EDDF.
|
// pool, at EDDF.
|
||||||
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
|
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
||||||
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
||||||
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
|
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { execSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
|
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
|
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
|
||||||
// right pane, in addition to the inline pane error) and game events appear as info toasts via
|
// right pane, in addition to the inline pane error) and game events appear as info toasts via
|
||||||
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
|
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
|
||||||
|
|||||||
+19
-4
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
|
|||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { windBurnFactor } from '../lib/sim/movement';
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
|
import { authenticate } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||||
|
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||||
|
|
||||||
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
@@ -18,6 +23,7 @@ import { windBurnFactor } from '../lib/sim/movement';
|
|||||||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
@@ -344,7 +350,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000×
|
||||||
const secondId = await buySecondPlaneViaUi(page);
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
// Full tanks for both — the starter's may be partial from an earlier run.
|
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||||
@@ -374,8 +380,9 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
|
|||||||
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||||||
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||||||
|
|
||||||
// Fast-forward to the FIRST landing (the short hop)…
|
// Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a
|
||||||
await setScale(page, SCALE);
|
// lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate.
|
||||||
|
await setScale(page, FAST);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -398,9 +405,17 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
|
|||||||
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||||
await setFuelFull(page, secondId);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
|
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs
|
||||||
|
// dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the
|
||||||
|
// range-edge return leg at SCALE (~24 s).
|
||||||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for both planes to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { createSession, hashPassword, requireAuth, verifyPassword } from './auth';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as
|
||||||
|
// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file.
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqWith = (sessionId?: string) =>
|
||||||
|
new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} });
|
||||||
|
|
||||||
|
describe('hashPassword / verifyPassword', () => {
|
||||||
|
it('round-trips a password through the stored digest', () => {
|
||||||
|
const hash = hashPassword('correct-horse-battery-staple');
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's
|
||||||
|
expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong password and malformed digests', () => {
|
||||||
|
const hash = hashPassword('some-password');
|
||||||
|
expect(verifyPassword('other-password', hash)).toBe(false);
|
||||||
|
expect(verifyPassword('x', 'not-a-digest')).toBe(false);
|
||||||
|
expect(verifyPassword('x', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAuth', () => {
|
||||||
|
it('401s when the header is missing or the session id is unknown', async () => {
|
||||||
|
for (const req of [reqWith(), reqWith('no-such-session')]) {
|
||||||
|
const guard = await requireAuth(req);
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) {
|
||||||
|
expect(guard.response.status).toBe(401);
|
||||||
|
expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401s an expired session', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]);
|
||||||
|
const guard = await requireAuth(reqWith('expired-sess'));
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) expect(guard.response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the seeded admin session to the local player row (joined on username)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const sessionId = await createSession(inst, 'admin-kamilkrzywda');
|
||||||
|
const guard = await requireAuth(reqWith(sessionId));
|
||||||
|
expect(guard.ok).toBe(true);
|
||||||
|
if (guard.ok) {
|
||||||
|
expect(guard.user.username).toBe('kamilkrzywda');
|
||||||
|
expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row
|
||||||
|
expect(guard.player.cash).toBe(20_000); // starter economy from the seed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500s when the account has no players row (broken data, not a client error)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [
|
||||||
|
hashPassword('whatever-123'),
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
const sessionId = await createSession(inst, 'user-no-player');
|
||||||
|
const guard = await requireAuth(reqWith(sessionId));
|
||||||
|
expect(guard.ok).toBe(false);
|
||||||
|
if (!guard.ok) {
|
||||||
|
expect(guard.response.status).toBe(500);
|
||||||
|
expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+54
-6
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
|
import type { PlayerRow } from './db/schema';
|
||||||
|
|
||||||
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
|
// 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
|
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
|
||||||
@@ -32,6 +33,15 @@ export function verifyPassword(password: string, storedHash: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
|
||||||
|
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
|
||||||
|
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
|
||||||
|
// verifies through verifyPassword().
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
||||||
// doesn't silently log everyone out mid-session.
|
// doesn't silently log everyone out mid-session.
|
||||||
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
||||||
@@ -46,11 +56,8 @@ export async function createSession(inst: GameDb, userId: string): Promise<strin
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
// Resolve a session id to a live (non-expired) user, or null.
|
||||||
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
|
||||||
const sessionId = req.headers.get(SESSION_HEADER);
|
|
||||||
if (!sessionId) return null;
|
|
||||||
const inst = await getGameDb();
|
|
||||||
const res = inst.raw.exec(
|
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 > ?',
|
'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()],
|
[sessionId, Date.now()],
|
||||||
@@ -60,6 +67,47 @@ export async function currentUser(req: Request): Promise<AuthUser | null> {
|
|||||||
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
||||||
|
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
||||||
|
const sessionId = req.headers.get(SESSION_HEADER);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return resolveSession(await getGameDb(), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthGuard =
|
||||||
|
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
|
||||||
|
| { ok: false; response: Response };
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
|
||||||
|
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
|
||||||
|
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
|
||||||
|
// (register and the mint hook always create both together — a miss means broken data).
|
||||||
|
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
|
||||||
|
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
|
||||||
|
const row = res[0]?.values?.[0];
|
||||||
|
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
inst,
|
||||||
|
user,
|
||||||
|
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req: Request): Promise<AuthGuard> {
|
||||||
|
return finishAuth(await currentUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
|
||||||
|
// ?session= query parameter (private infra; every other game route is header-only).
|
||||||
|
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
|
||||||
|
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
|
||||||
|
if (!sessionId) return finishAuth(null);
|
||||||
|
return finishAuth(await resolveSession(await getGameDb(), sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
|
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.
|
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// v1.1.0 (ISS-107) — client-side session handling shared by every authenticated fetcher, the
|
||||||
|
// sign-in screen and the admin panel. The server mints sessions at /api/login + /api/register;
|
||||||
|
// we only store the id in localStorage and echo it back as X-Flight-Session.
|
||||||
|
|
||||||
|
export const SESSION_STORAGE_KEY = 'flight.session';
|
||||||
|
|
||||||
|
export function storedSession(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null; // storage blocked (private mode) — treat as signed out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers for an authenticated fetch. Empty object when no session is stored, so callers can
|
||||||
|
// spread it into their existing header objects without branching.
|
||||||
|
export function sessionHeaders(session: string | null = storedSession()): Record<string, string> {
|
||||||
|
return session ? { 'X-Flight-Session': session } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeSession(sessionId: string): void {
|
||||||
|
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredSession(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// storage blocked — nothing to clear
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import initSqlJs from 'sql.js';
|
import initSqlJs from 'sql.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
|
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||||
@@ -88,6 +89,40 @@ CREATE TABLE sim_state (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
|
||||||
|
// file for the owner_id backfill migration test.
|
||||||
|
const PRE_OWNERSHIP_DDL = `
|
||||||
|
CREATE TABLE planes (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
fuel REAL NOT NULL,
|
||||||
|
max_fuel REAL NOT NULL,
|
||||||
|
location_icao TEXT NOT NULL,
|
||||||
|
dest_icao TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
departure_sim_time INTEGER,
|
||||||
|
queue TEXT NOT NULL DEFAULT '[]',
|
||||||
|
health REAL NOT NULL DEFAULT 100,
|
||||||
|
repair_until_sim_time INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE players (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
cash REAL NOT NULL,
|
||||||
|
reputation REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE sim_state (
|
||||||
|
clock_ms INTEGER NOT NULL,
|
||||||
|
time_scale REAL NOT NULL,
|
||||||
|
paused INTEGER NOT NULL,
|
||||||
|
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -350,4 +385,32 @@ describe('openGameDb', () => {
|
|||||||
expect(tables.has('account')).toBe(false);
|
expect(tables.has('account')).toBe(false);
|
||||||
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
|
||||||
|
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(PRE_OWNERSHIP_DDL);
|
||||||
|
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
|
||||||
|
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
|
||||||
|
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
|
||||||
|
const file = path.join(dir, 'pre-ownership.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
|
// Every legacy plane is backfilled to the local player…
|
||||||
|
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||||
|
expect(owners).toEqual(['local', 'local']);
|
||||||
|
// …and the world was NOT re-seeded (the players row already existed).
|
||||||
|
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||||
|
|
||||||
|
// Second open is a no-op: an owner change made after the migration survives a reopen.
|
||||||
|
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
|
||||||
|
await persist(inst);
|
||||||
|
const second = await openGameDb(file);
|
||||||
|
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||||
|
expect(owners2).toEqual(['local', 'someone-else']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-1
@@ -23,6 +23,9 @@ export interface GameDb {
|
|||||||
const DDL = `
|
const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS planes (
|
CREATE TABLE IF NOT EXISTS planes (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
|
||||||
|
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
fuel REAL NOT NULL,
|
fuel REAL NOT NULL,
|
||||||
@@ -159,6 +162,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
||||||
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
||||||
}
|
}
|
||||||
|
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
|
||||||
|
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
|
||||||
|
// constant default, which SQLite applies to every existing row at ALTER time).
|
||||||
|
if (!cols('planes').has('owner_id')) {
|
||||||
|
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||||
@@ -267,7 +276,8 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
|||||||
|
|
||||||
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||||
const { queue, ...planeCols } = data.plane;
|
const { queue, ...planeCols } = data.plane;
|
||||||
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
|
// v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
|
||||||
|
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
|
||||||
await instance.db.insert(schema.packages).values(data.packages);
|
await instance.db.insert(schema.packages).values(data.packages);
|
||||||
// v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
|
// v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
|
||||||
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
|
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
|
||||||
|
|||||||
+31
-5
@@ -1,11 +1,12 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq, max } from 'drizzle-orm';
|
||||||
import type { GameDb } from './client';
|
import type { GameDb } from './client';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
// v1.0.0 (ISS-105): TEMPORARY single-player economy access — resolves the local player row so all
|
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
||||||
// pre-multiplayer code keeps working while `account` is replaced by `players`. A later step
|
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
|
||||||
// parameterizes these by playerId for real multiplayer; until then every read/write of the
|
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
|
||||||
// singleton economy goes through this file and nowhere else.
|
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
|
||||||
|
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
|
||||||
|
|
||||||
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||||
|
|
||||||
@@ -21,3 +22,28 @@ export async function updateLocalPlayerEconomy(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and
|
||||||
|
// session-scoped snapshots thread the acting player's id through and read/write THAT row; the
|
||||||
|
// 'local' helpers above are reserved for world-level reads with no acting session.
|
||||||
|
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
|
||||||
|
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
|
||||||
|
if (!row) throw new Error(`player ${id} missing`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlayerEconomy(
|
||||||
|
inst: GameDb,
|
||||||
|
id: string,
|
||||||
|
patch: { cash?: number; reputation?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
|
||||||
|
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
|
||||||
|
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
|
||||||
|
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
|
||||||
|
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
|
||||||
|
return row?.m ?? 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|||||||
|
|
||||||
export const planes = sqliteTable('planes', {
|
export const planes = sqliteTable('planes', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
|
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
|
||||||
|
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
|
||||||
|
// onboarding stamp it explicitly on insert.
|
||||||
|
ownerId: text('owner_id').notNull(),
|
||||||
model: text('model').notNull(),
|
model: text('model').notNull(),
|
||||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
||||||
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
repairUntilSimTime: null,
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePackage = (id: string): PackageState => ({
|
const basePackage = (id: string): PackageState => ({
|
||||||
@@ -44,12 +46,14 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes: [basePlane('plane-starter')],
|
planes: [basePlane('plane-starter')],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
|
|||||||
+163
-82
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
repRate,
|
repRate,
|
||||||
SELL_FRACTION,
|
SELL_FRACTION,
|
||||||
} from './catalog';
|
} from './catalog';
|
||||||
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb('load-ok.db');
|
const inst = await freshDb('load-ok.db');
|
||||||
await insertOpenPackage(inst);
|
await insertOpenPackage(inst);
|
||||||
|
|
||||||
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
|
||||||
|
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||||
expect(row.status).toBe('loaded');
|
expect(row.status).toBe('loaded');
|
||||||
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb(`load-${status}.db`);
|
const inst = await freshDb(`load-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rejects an unknown package id with 404', async () => {
|
it('rejects an unknown package id with 404', async () => {
|
||||||
const inst = await freshDb('load-punknown.db');
|
const inst = await freshDb('load-punknown.db');
|
||||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('load-ghost.db');
|
const inst = await freshDb('load-ghost.db');
|
||||||
await insertOpenPackage(inst);
|
await insertOpenPackage(inst);
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a plane that is not at the origin with 409', async () => {
|
it('rejects a plane that is not at the origin with 409', async () => {
|
||||||
@@ -137,7 +137,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst); // originates at EDDF
|
await insertOpenPackage(inst); // originates at EDDF
|
||||||
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
||||||
@@ -149,7 +149,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
||||||
}
|
}
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts expired packages as occupying their payload too', async () => {
|
it('counts expired packages as occupying their payload too', async () => {
|
||||||
@@ -160,7 +160,7 @@ describe('loadPackage', () => {
|
|||||||
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||||||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ describe('flyPlane', () => {
|
|||||||
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||||
const inst = await freshDb('fly-ok.db');
|
const inst = await freshDb('fly-ok.db');
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -188,17 +188,17 @@ describe('flyPlane', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('fly-ghost.db');
|
const inst = await freshDb('fly-ghost.db');
|
||||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('fly-unknownicao.db');
|
const inst = await freshDb('fly-unknownicao.db');
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
||||||
@@ -207,7 +207,7 @@ describe('flyPlane', () => {
|
|||||||
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /broken \(health 0\)/);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // still grounded…
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
@@ -219,7 +219,7 @@ describe('flyPlane', () => {
|
|||||||
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
||||||
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // no 409 — worn is not broken
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -253,7 +253,7 @@ describe('flyPlane', () => {
|
|||||||
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
||||||
@@ -274,7 +274,7 @@ describe('flyPlane', () => {
|
|||||||
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -289,7 +289,7 @@ describe('flyPlane', () => {
|
|||||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /insufficient funds/i);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // still grounded…
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
@@ -301,17 +301,17 @@ describe('flyPlane', () => {
|
|||||||
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
|
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
|
||||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD', 'local'), 409, /insufficient fuel/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||||
|
|
||||||
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||||
const inst = await freshDb('fly-consume.db');
|
const inst = await freshDb('fly-consume.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
|
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -321,10 +321,10 @@ describe('flyPlane', () => {
|
|||||||
|
|
||||||
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||||
const inst = await freshDb('fly-skipahead.db');
|
const inst = await freshDb('fly-skipahead.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
|
await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -337,11 +337,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
it('appends legs to the queue in order and persists them as JSON', async () => {
|
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||||
const inst = await freshDb('qleg-ok.db');
|
const inst = await freshDb('qleg-ok.db');
|
||||||
|
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||||
|
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
|
||||||
plane = (await inst.db.select().from(schema.planes))[0];
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||||||
});
|
});
|
||||||
@@ -352,7 +352,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying'); // still on its active leg…
|
expect(plane.status).toBe('flying'); // still on its active leg…
|
||||||
@@ -362,19 +362,19 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qleg-ghost.db');
|
const inst = await freshDb('qleg-ghost.db');
|
||||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('qleg-unknownicao.db');
|
const inst = await freshDb('qleg-unknownicao.db');
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||||
const inst = await freshDb('qleg-dup.db');
|
const inst = await freshDb('qleg-dup.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
|
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||||
@@ -386,7 +386,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||||
@@ -394,8 +394,8 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
|
|
||||||
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||||
const inst = await freshDb('qleg-dupother.db');
|
const inst = await freshDb('qleg-dupother.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||||
@@ -405,11 +405,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||||
const inst = await freshDb('qcancel-ok.db');
|
const inst = await freshDb('qcancel-ok.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
|
||||||
|
|
||||||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||||||
@@ -417,19 +417,19 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
|||||||
|
|
||||||
it('rejects an out-of-range index with 400', async () => {
|
it('rejects an out-of-range index with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-oob.db');
|
const inst = await freshDb('qcancel-oob.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cancelling from an empty queue with 400', async () => {
|
it('rejects cancelling from an empty queue with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qcancel-ghost.db');
|
const inst = await freshDb('qcancel-ghost.db');
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await freshDb(name);
|
const inst = await freshDb(name);
|
||||||
// Always insert OPEN and load it through the real action…
|
// Always insert OPEN and load it through the real action…
|
||||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
||||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
await loadPackage(inst, 'p-ab', 'plane-starter', 'local');
|
||||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||||
if (status === 'expired') {
|
if (status === 'expired') {
|
||||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||||||
@@ -454,7 +454,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded('ab-start.db');
|
const inst = await setupLoaded('ab-start.db');
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
@@ -468,7 +468,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded('ab-mid.db');
|
const inst = await setupLoaded('ab-mid.db');
|
||||||
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||||||
});
|
});
|
||||||
@@ -481,7 +481,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded(name);
|
const inst = await setupLoaded(name);
|
||||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -490,7 +490,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded('ab-expired.db', 'expired');
|
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(0);
|
expect(penalty).toBe(0);
|
||||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||||
@@ -502,7 +502,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded('ab-floor.db');
|
const inst = await setupLoaded('ab-floor.db');
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||||
|
|
||||||
expect(penalty).toBe(150);
|
expect(penalty).toBe(150);
|
||||||
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||||
@@ -514,19 +514,19 @@ describe('abandonPackage', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||||
const inst = await freshDb(`ab-${status}.db`);
|
const inst = await freshDb(`ab-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('ab-unknown.db');
|
const inst = await freshDb('ab-unknown.db');
|
||||||
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,7 +536,7 @@ describe('buyPlane', () => {
|
|||||||
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||||
|
|
||||||
const before = (await inst.db.select().from(schema.planes)).length;
|
const before = (await inst.db.select().from(schema.planes)).length;
|
||||||
const { planeId } = await buyPlane(inst, 'atr72');
|
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||||
|
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||||
@@ -557,12 +557,12 @@ describe('buyPlane', () => {
|
|||||||
|
|
||||||
it('rejects an unknown catalog model with 400', async () => {
|
it('rejects an unknown catalog model with 400', async () => {
|
||||||
const inst = await freshDb('buy-unknown.db');
|
const inst = await freshDb('buy-unknown.db');
|
||||||
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
|
await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects the purchase when cash is below the price with 409', async () => {
|
it('rejects the purchase when cash is below the price with 409', async () => {
|
||||||
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
||||||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||||||
@@ -571,7 +571,7 @@ describe('buyPlane', () => {
|
|||||||
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
||||||
|
|
||||||
const before = (await inst.db.select().from(schema.planes)).length;
|
const before = (await inst.db.select().from(schema.planes)).length;
|
||||||
await buyPlane(inst, 'atr72');
|
await buyPlane(inst, 'atr72', 'local');
|
||||||
|
|
||||||
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||||||
@@ -587,14 +587,14 @@ describe('buyPlane', () => {
|
|||||||
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
|
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
|
||||||
|
|
||||||
const { planeId } = await buyPlane(inst, 'atr72');
|
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||||
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||||
|
|
||||||
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||||
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
||||||
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
|
await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,12 +603,12 @@ describe('sellPlane', () => {
|
|||||||
const inst = await freshDb('sell-ok.db');
|
const inst = await freshDb('sell-ok.db');
|
||||||
// A second plane so the starter is not "the last remaining one".
|
// A second plane so the starter is not "the last remaining one".
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { payout } = await sellPlane(inst, 'plane-extra');
|
const { payout } = await sellPlane(inst, 'plane-extra', 'local');
|
||||||
|
|
||||||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
@@ -621,22 +621,22 @@ describe('sellPlane', () => {
|
|||||||
it('rejects selling a flying plane with 409', async () => {
|
it('rejects selling a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-flying.db');
|
const inst = await freshDb('sell-flying.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||||
const inst = await freshDb('sell-cargo.db');
|
const inst = await freshDb('sell-cargo.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
|
||||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||||
});
|
});
|
||||||
@@ -644,24 +644,24 @@ describe('sellPlane', () => {
|
|||||||
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||||
const inst = await freshDb('sell-queue.db');
|
const inst = await freshDb('sell-queue.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await queueLeg(inst, 'plane-extra', 'LEMD');
|
await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
|
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
|
||||||
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects selling the last remaining plane with 409', async () => {
|
it('rejects selling the last remaining plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('sell-unknown.db');
|
const inst = await freshDb('sell-unknown.db');
|
||||||
await expectActionError(sellPlane(inst, 'ghost'), 404);
|
await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
expect(cost).toBe(REPAIR_COST);
|
expect(cost).toBe(REPAIR_COST);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -686,7 +686,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||||
|
|
||||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
expect(cost).toBe(0);
|
expect(cost).toBe(0);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -702,7 +702,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a plane that is already repairing with 409', async () => {
|
it('rejects a plane that is already repairing with 409', async () => {
|
||||||
@@ -711,23 +711,23 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||||
const inst = await freshDb('repair-blocks.db');
|
const inst = await freshDb('repair-blocks.db');
|
||||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await repairPlane(inst, 'plane-starter');
|
await repairPlane(inst, 'plane-starter', 'local');
|
||||||
|
|
||||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
|
||||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||||
@@ -735,11 +735,92 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
||||||
|
|
||||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('repair-unknown.db');
|
const inst = await freshDb('repair-unknown.db');
|
||||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ownership (v1.2.0, ISS-109)', () => {
|
||||||
|
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
|
||||||
|
async function addPlayerB(inst: GameDb): Promise<void> {
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
|
||||||
|
const inst = await freshDb('own-403.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await insertOpenPackage(inst, 'p-own');
|
||||||
|
|
||||||
|
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
// Load as the owner first so abandon has a bound package to check…
|
||||||
|
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
|
||||||
|
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
// Nothing was mutated by the rejected attempts.
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]);
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
|
||||||
|
const inst = await freshDb('own-403-rev.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the last-plane guard per owner — not on the world total', async () => {
|
||||||
|
const inst = await freshDb('own-lastplane.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
// 'b' owns two planes; 'local' only the starter. World total: three.
|
||||||
|
for (const id of ['plane-b1', 'plane-b2']) {
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selling local's only plane is still forbidden even though the world has three planes…
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||||
|
// …while b can sell one of their two.
|
||||||
|
const cashBefore = (await getPlayer(inst, 'b')).cash;
|
||||||
|
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
|
||||||
|
|
||||||
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
|
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
|
||||||
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
|
||||||
|
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
|
||||||
|
const inst = await freshDb('own-buy.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
|
||||||
|
|
||||||
|
const { planeId } = await buyPlane(inst, 'atr72', 'b');
|
||||||
|
|
||||||
|
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
|
||||||
|
expect(bought.ownerId).toBe('b');
|
||||||
|
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+52
-24
@@ -1,7 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { persist, type GameDb } from '@/lib/db/client';
|
import { persist, type GameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
@@ -34,6 +35,7 @@ import { HUB_ICAO } from './seed';
|
|||||||
// 404 — unknown entity id
|
// 404 — unknown entity id
|
||||||
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
||||||
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
|
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
|
||||||
|
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
|
||||||
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
||||||
|
|
||||||
export class GameActionError extends Error {
|
export class GameActionError extends Error {
|
||||||
@@ -53,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
|
|||||||
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
|
||||||
|
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
|
||||||
|
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
||||||
|
|
||||||
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
||||||
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
|
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
|
||||||
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
||||||
// delivered/abandoned).
|
// delivered/abandoned).
|
||||||
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
|
||||||
const [pkgRows, planeRows] = await Promise.all([
|
const [pkgRows, planeRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
||||||
@@ -71,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
}
|
}
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
||||||
}
|
}
|
||||||
@@ -110,10 +118,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
|
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
|
||||||
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
||||||
// 409s before any refuel math, so fuel and cash stay untouched.
|
// 409s before any refuel math, so fuel and cash stay untouched.
|
||||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
|
||||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
||||||
}
|
}
|
||||||
@@ -141,7 +150,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
// any mutation, so a 409 leaves fuel and cash untouched.
|
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||||
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||||
const liters = Math.max(0, target - plane.fuel);
|
const liters = Math.max(0, target - plane.fuel);
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
|
||||||
|
const player = await getPlayer(inst, playerId);
|
||||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
if (cost > 0 && player.cash < cost) {
|
if (cost > 0 && player.cash < cost) {
|
||||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
||||||
@@ -173,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||||
}
|
}
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
@@ -185,10 +195,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
||||||
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||||||
// in the airport dataset.
|
// in the airport dataset.
|
||||||
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
|
||||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||||||
@@ -215,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
||||||
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||||
// known plane, integer index within [0, queue.length).
|
// known plane, integer index within [0, queue.length).
|
||||||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
|
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
|
||||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
|
|
||||||
const queue = parseIcaoQueue(plane.queue);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
if (index < 0 || index >= queue.length) {
|
if (index < 0 || index >= queue.length) {
|
||||||
@@ -235,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
|
|||||||
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
||||||
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
||||||
// goes below €0. The slot is freed immediately.
|
// goes below €0. The slot is freed immediately.
|
||||||
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
|
export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
|
||||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
const pkg = pkgRows[0];
|
const pkg = pkgRows[0];
|
||||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||||
@@ -247,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
||||||
}
|
}
|
||||||
@@ -255,8 +268,9 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
const penalty = abandonPenalty(pkg, sim.clockMs);
|
const penalty = abandonPenalty(pkg, sim.clockMs);
|
||||||
|
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
|
const player = await getPlayer(inst, playerId);
|
||||||
|
await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
|
||||||
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { penalty };
|
return { penalty };
|
||||||
@@ -267,11 +281,12 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
|||||||
// 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
|
||||||
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
||||||
// at the home base (EDDF).
|
// at the home base (EDDF).
|
||||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
|
||||||
const type = PLANE_TYPES[model];
|
const type = PLANE_TYPES[model];
|
||||||
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
||||||
|
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
|
||||||
|
const player = await getPlayer(inst, playerId);
|
||||||
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
|
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
|
||||||
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||||||
if (player.cash < cost) {
|
if (player.cash < cost) {
|
||||||
@@ -285,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
const planeId = randomUUID();
|
const planeId = randomUUID();
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: planeId,
|
id: planeId,
|
||||||
|
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
|
||||||
model,
|
model,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
fuel: type.maxFuel, // full tank on delivery
|
fuel: type.maxFuel, // full tank on delivery
|
||||||
@@ -296,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||||
});
|
});
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { planeId };
|
return { planeId };
|
||||||
}
|
}
|
||||||
@@ -305,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
|
|
||||||
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
|
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
|
||||||
// remaining plane can never be sold — the company needs at least one aircraft.
|
// remaining plane can never be sold — the company needs at least one aircraft.
|
||||||
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
|
export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
|
||||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
||||||
}
|
}
|
||||||
@@ -327,16 +344,18 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await inst.db.select().from(schema.planes);
|
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
|
||||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
// players' planes exist in the world, and selling one of two of yours is fine.
|
||||||
|
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
|
||||||
|
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||||
|
|
||||||
const type = PLANE_TYPES[plane.model];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
||||||
|
|
||||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getPlayer(inst, playerId);
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { payout };
|
return { payout };
|
||||||
}
|
}
|
||||||
@@ -349,10 +368,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||||||
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||||||
// plane; 409 not grounded / insufficient funds.
|
// plane; 409 not grounded / insufficient funds.
|
||||||
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
|
||||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||||
}
|
}
|
||||||
@@ -361,7 +381,8 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
|
|||||||
|
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
|
||||||
|
const player = await getPlayer(inst, playerId);
|
||||||
if (player.cash < REPAIR_COST) {
|
if (player.cash < REPAIR_COST) {
|
||||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
||||||
}
|
}
|
||||||
@@ -370,7 +391,7 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
|
|||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { cost: REPAIR_COST };
|
return { cost: REPAIR_COST };
|
||||||
}
|
}
|
||||||
@@ -381,10 +402,17 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
|
|||||||
// game time — then respond with the FULL current state snapshot (same shape as
|
// game time — then respond with the FULL current state snapshot (same shape as
|
||||||
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
|
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
|
||||||
// its own status + {error}; anything unexpected is a 500.
|
// its own status + {error}; anything unexpected is a 500.
|
||||||
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
|
// v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
|
||||||
|
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
|
||||||
|
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
|
||||||
|
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
|
||||||
|
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
|
||||||
|
const auth = await requireAuth(req);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
try {
|
try {
|
||||||
await runAction(fn);
|
await runAction((inst) => fn(inst, auth.player.id));
|
||||||
return Response.json(await getStateSnapshot());
|
// v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`.
|
||||||
|
return Response.json(await getStateSnapshot(auth.player.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GameActionError) {
|
if (err instanceof GameActionError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status });
|
return Response.json({ error: err.message }, { status: err.status });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { travelDurationMs } from '@/lib/sim/movement';
|
import { legPhases } from '@/lib/sim/movement';
|
||||||
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
import { SPECIALS } from './specials';
|
import { SPECIALS } from './specials';
|
||||||
import {
|
import {
|
||||||
@@ -478,7 +478,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
|
|||||||
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
|
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 o = airports.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = airports.find((a) => a.icao === c.destIcao)!;
|
const d = airports.find((a) => a.icao === c.destIcao)!;
|
||||||
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
// v1.12.0: the floor includes the weather-scaled takeoff/landing phases (worst case for this pair).
|
||||||
|
const floorMs = legPhases(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH, o.icao, d.icao).totalMs + EXPIRY_BUFFER_MS;
|
||||||
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
|
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -503,7 +504,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
|
|||||||
}
|
}
|
||||||
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
|
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
|
||||||
|
|
||||||
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
// v1.12.0: phase-inclusive floor — same composition seed.ts uses for this pair.
|
||||||
|
const floorMs = legPhases(bestKm, FASTEST_CATALOG_SPEED_KMH, airports[bestI].icao, airports[bestJ].icao).totalMs + EXPIRY_BUFFER_MS;
|
||||||
for (const special of ['priority', null] as const) {
|
for (const special of ['priority', null] as const) {
|
||||||
let clamped = 0;
|
let clamped = 0;
|
||||||
for (let seed = 1; seed <= 50; seed++) {
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
|||||||
+24
-15
@@ -1,5 +1,5 @@
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { travelDurationMs } from '@/lib/sim/movement';
|
import { legPhases } from '@/lib/sim/movement';
|
||||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import { FASTEST_CATALOG_SPEED_KMH, 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';
|
||||||
@@ -284,9 +284,11 @@ export function generatePackage(input: {
|
|||||||
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs
|
// 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
|
// (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
|
// 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
|
// airports on Earth (max leg ~20000 km → floor ≈ 23.5 h incl. worst-case phases < the 36 h priority
|
||||||
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling.
|
// ceiling), so no separate ceiling raise is needed. Forced offsets skip the floor, same as they skip
|
||||||
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
// the special scaling. v1.12.0: the travel time includes the weather-scaled takeoff/landing phases —
|
||||||
|
// a package must be finishable even when both airports are in their worst (Fog) conditions.
|
||||||
|
const expiryFloorMs = legPhases(km, FASTEST_CATALOG_SPEED_KMH, origin.icao, dest.icao).totalMs + EXPIRY_BUFFER_MS;
|
||||||
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
|
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.
|
||||||
@@ -360,19 +362,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
|
|||||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSeed(input: {
|
// v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
|
||||||
airports: SeedAirport[];
|
// it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
|
||||||
rng: Rng;
|
// caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
|
||||||
nowMs: number;
|
export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
|
||||||
}): SeedData {
|
|
||||||
const { airports, rng, nowMs } = input;
|
|
||||||
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
|
||||||
|
|
||||||
const type = PLANE_TYPES[STARTER_MODEL];
|
const type = PLANE_TYPES[STARTER_MODEL];
|
||||||
const hub = pickHub(airports);
|
const hub = pickHub(airports);
|
||||||
|
return {
|
||||||
const plane: SeedPlane = {
|
id,
|
||||||
id: 'plane-starter',
|
|
||||||
model: STARTER_MODEL,
|
model: STARTER_MODEL,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
fuel: type.maxFuel, // starts full
|
fuel: type.maxFuel, // starts full
|
||||||
@@ -384,6 +381,18 @@ export function buildSeed(input: {
|
|||||||
queue: [], // no queued legs on first run (v0.25.0)
|
queue: [], // no queued legs on first run (v0.25.0)
|
||||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSeed(input: {
|
||||||
|
airports: SeedAirport[];
|
||||||
|
rng: Rng;
|
||||||
|
nowMs: number;
|
||||||
|
}): SeedData {
|
||||||
|
const { airports, rng, nowMs } = input;
|
||||||
|
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
||||||
|
|
||||||
|
const plane = buildStarterPlane(airports, 'plane-starter');
|
||||||
|
const hub = pickHub(airports);
|
||||||
|
|
||||||
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
|
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
|
||||||
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
||||||
|
|||||||
+56
-2
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import fs from 'node:fs/promises';
|
||||||
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { PLANE_TYPES } from './catalog';
|
||||||
|
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
|
||||||
|
import { getStateSnapshot, 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.
|
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
||||||
|
|
||||||
@@ -20,6 +27,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
|||||||
status,
|
status,
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||||
@@ -29,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
|||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
sellFraction: 0.5,
|
sellFraction: 0.5,
|
||||||
planes: [],
|
planes: [],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
|
||||||
packages,
|
packages,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,3 +68,48 @@ describe('sseFrame', () => {
|
|||||||
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115) — the snapshot's players array: one row per player with cash/reputation and
|
||||||
|
// how many planes they own. Runs against a throwaway seeded DB via the app singleton, exactly
|
||||||
|
// like the /api/state route tests (fresh module registry per file ⇒ fresh singleton).
|
||||||
|
describe('getStateSnapshot players', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-snapshot-players-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'players.db');
|
||||||
|
await getGameDb(); // seeds the local player + starter plane
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-player regression guard — one row for the seeded local account', async () => {
|
||||||
|
const snap = await getStateSnapshot('local');
|
||||||
|
expect(snap.players).toEqual([
|
||||||
|
{ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two players — cash/reputation/planeCount per owner (v1.6.0, ISS-115)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second account with a distinct economy and two planes of its own — rows minted like /api/register does.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: 5_000, reputation: 3 });
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
for (const id of ['plane-b-1', 'plane-b-2']) {
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id, ownerId: 'b', model: 'atr72', status: 'available',
|
||||||
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap = await getStateSnapshot('b'); // any session sees the whole world's players
|
||||||
|
expect(snap.players).toHaveLength(2);
|
||||||
|
const local = snap.players.find((p) => p.id === 'local')!;
|
||||||
|
expect(local).toEqual({ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 }); // starter still counted once…
|
||||||
|
const b = snap.players.find((p) => p.id === 'b')!;
|
||||||
|
expect(b).toEqual({ id: 'b', username: 'player-b', cash: 5_000, reputation: 3, planeCount: 2 }); // …and both of B's planes count for B.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+60
-7
@@ -2,7 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
import { getPlayer } from '@/lib/db/localPlayer';
|
||||||
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';
|
||||||
@@ -37,6 +37,10 @@ export interface PlaneState {
|
|||||||
health: number;
|
health: number;
|
||||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||||
repairUntilSimTime: number | null;
|
repairUntilSimTime: number | null;
|
||||||
|
// v1.4.0 (ISS-112): owner info — the snapshot carries ALL planes (shared world), so every row
|
||||||
|
// names its owner (players.id + username) and the panes can tell whose plane is whose.
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||||
@@ -88,6 +92,19 @@ export interface PackageState {
|
|||||||
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
|
// 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).
|
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
||||||
expiresRealMs: number | null;
|
expiresRealMs: number | null;
|
||||||
|
// v1.4.0 (ISS-112): carrier name — players.username of planes.owner_id via planeId, so an
|
||||||
|
// assigned package shows WHO is carrying it in the shared world; null when unassigned.
|
||||||
|
carrierName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): per-player summary for the leaderboard panel — rides on every snapshot
|
||||||
|
// (GET /api/state + SSE frames), so no new endpoint is needed. planeCount = planes owned.
|
||||||
|
export interface PlayerSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
cash: number;
|
||||||
|
reputation: number;
|
||||||
|
planeCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateSnapshot {
|
export interface StateSnapshot {
|
||||||
@@ -96,6 +113,9 @@ export interface StateSnapshot {
|
|||||||
// only the clock itself is reported here.
|
// only the clock itself is reported here.
|
||||||
simState: { clockMs: number };
|
simState: { clockMs: number };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
|
// v1.6.0 (ISS-115): leaderboard data — one row per player, projected from the same players +
|
||||||
|
// planes reads as everything else in this snapshot (no extra query).
|
||||||
|
players: PlayerSummary[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||||
// the client can compute buy/sell previews without a separate endpoint.
|
// the client can compute buy/sell previews without a separate endpoint.
|
||||||
@@ -114,19 +134,26 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
|
|||||||
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
|
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
// v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
|
||||||
|
// shape as before ({cash, reputation}), sourced per-player. v1.4.0 (ISS-112): this IS the shared
|
||||||
|
// projection for every surface that serves state — GET /api/state, action responses, the SSE
|
||||||
|
// connect frame and each tick's broadcast all call it with a player id (the broadcast projects
|
||||||
|
// once PER CONNECTED SESSION), so no two surfaces can drift. Planes are ALL planes in the world,
|
||||||
|
// each carrying owner info; packages carry carrierName when assigned to a plane.
|
||||||
|
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
const { db } = inst;
|
const { db } = inst;
|
||||||
// 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
|
||||||
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
||||||
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
||||||
// v1.0.0 (ISS-105): the snapshot's `account` field is now the local player row — same shape,
|
const [player, simRows, planeRows, packageRows, playerRows] = await Promise.all([
|
||||||
// resolved through the temporary single-player helper until multiplayer lands.
|
getPlayer(inst, playerId),
|
||||||
const [player, simRows, planeRows, packageRows] = await Promise.all([
|
|
||||||
getLocalPlayer(inst),
|
|
||||||
db.select().from(schema.simState),
|
db.select().from(schema.simState),
|
||||||
db.select().from(schema.planes),
|
db.select().from(schema.planes),
|
||||||
db.select().from(schema.packages),
|
db.select().from(schema.packages),
|
||||||
|
// v1.4.0 (ISS-112): one players read serves both owner joins — plane rows via ownerId and
|
||||||
|
// package carrier names via planeId → planes.owner_id → players.username.
|
||||||
|
db.select().from(schema.players),
|
||||||
]);
|
]);
|
||||||
const simRow = simRows[0];
|
const simRow = simRows[0];
|
||||||
if (!simRow) throw new Error('game state missing sim_state row');
|
if (!simRow) throw new Error('game state missing sim_state row');
|
||||||
@@ -147,6 +174,15 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
// body entirely, so clients never see jobs they could not dispatch.
|
// body entirely, so clients never see jobs they could not dispatch.
|
||||||
const pool = poolAirports(planeRows);
|
const pool = poolAirports(planeRows);
|
||||||
|
|
||||||
|
// v1.4.0 (ISS-112): owner joins — every plane row names its owner; a package's carrier is the
|
||||||
|
// username of ITS PLANE'S owner (planeId → planes.owner_id → players.username), null when unassigned.
|
||||||
|
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
||||||
|
const planeById = new Map(planeRows.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): leaderboard — count owned planes per player from the rows already loaded above.
|
||||||
|
const planeCountByOwner = new Map<string, number>();
|
||||||
|
for (const p of planeRows) planeCountByOwner.set(p.ownerId, (planeCountByOwner.get(p.ownerId) ?? 0) + 1);
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -156,7 +192,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
const to = coordsByIcao.get(p.destIcao);
|
const to = coordsByIcao.get(p.destIcao);
|
||||||
if (!type || !from || !to) return null;
|
if (!type || !from || !to) return null;
|
||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
// v1.12.0: piecewise cruise progress — holds at the origin during takeoff and at the
|
||||||
|
// destination during landing (weather-scaled phase durations from legPhases).
|
||||||
|
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao, simRow.clockMs);
|
||||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||||
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
||||||
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||||
@@ -195,8 +233,18 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
queue: parseIcaoQueue(p.queue),
|
queue: parseIcaoQueue(p.queue),
|
||||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||||
repairUntilSimTime: p.repairUntilSimTime,
|
repairUntilSimTime: p.repairUntilSimTime,
|
||||||
|
ownerId: p.ownerId, // v1.4.0 (ISS-112)
|
||||||
|
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
// v1.6.0 (ISS-115): leaderboard rows — every player in the world with their economy + fleet size.
|
||||||
|
players: playerRows.map((pl) => ({
|
||||||
|
id: pl.id,
|
||||||
|
username: pl.username,
|
||||||
|
cash: pl.cash,
|
||||||
|
reputation: pl.reputation,
|
||||||
|
planeCount: planeCountByOwner.get(pl.id) ?? 0,
|
||||||
|
})),
|
||||||
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).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,
|
||||||
@@ -214,6 +262,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
createdSimTime: p.createdSimTime,
|
createdSimTime: p.createdSimTime,
|
||||||
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
|
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
|
||||||
|
// v1.4.0 (ISS-112): carrier = the CARRYING PLANE's owner — unassigned packages stay null.
|
||||||
|
carrierName: (() => {
|
||||||
|
const plane = p.planeId ? planeById.get(p.planeId) : undefined;
|
||||||
|
return plane ? usernameByPlayerId.get(plane.ownerId) ?? plane.ownerId : null;
|
||||||
|
})(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-28
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes: [],
|
planes: [],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: [
|
catalog: [
|
||||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
||||||
@@ -55,8 +56,8 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||||
@@ -83,7 +84,7 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
snapshot({
|
||||||
catalog: [],
|
catalog: [],
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -95,7 +96,7 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
||||||
@@ -113,7 +114,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -127,7 +128,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -148,7 +149,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -161,7 +162,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -173,7 +174,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -185,7 +186,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -197,7 +198,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -217,7 +218,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -229,7 +230,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -246,7 +247,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -258,8 +259,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -273,7 +274,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -288,7 +289,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -298,7 +299,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -308,7 +309,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a flying plane with an empty queue', () => {
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -319,7 +320,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -336,8 +337,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -347,9 +348,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -491,7 +492,7 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
|||||||
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -529,7 +530,7 @@ describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (I
|
|||||||
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
|
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
|
||||||
const snap = snapshot({
|
const snap = snapshot({
|
||||||
planes: [
|
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: [] },
|
{ 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
||||||
|
|||||||
+25
-2
@@ -1,6 +1,7 @@
|
|||||||
import type { PlaneState, StateSnapshot } from './game/state';
|
import type { PlaneState, StateSnapshot } from './game/state';
|
||||||
import { formatFuel } from './format';
|
import { formatFuel } from './format';
|
||||||
import { positionAt } from './sim/movement';
|
import { haversineKm } from './geo';
|
||||||
|
import { legPhaseAt, positionAt, type LegPhase } from './sim/movement';
|
||||||
|
|
||||||
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
|
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
|
||||||
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
|
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
|
||||||
@@ -14,9 +15,14 @@ export interface PlaneFeatureProps {
|
|||||||
status: 'available' | 'flying' | 'repairing';
|
status: 'available' | 'flying' | 'repairing';
|
||||||
fuelText: string; // "1234 / 6300 L"
|
fuelText: string; // "1234 / 6300 L"
|
||||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||||
|
// v1.12.0: 'taking off' | 'cruising' | 'landing' while flying (derived from the sim clock), '' when grounded.
|
||||||
|
phaseLabel: string;
|
||||||
flying: boolean;
|
flying: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.12.0: display text for each timed leg segment (see legPhaseAt in lib/sim/movement.ts).
|
||||||
|
const PHASE_LABELS: Record<LegPhase, string> = { takeoff: 'taking off', cruise: 'cruising', landing: 'landing' };
|
||||||
|
|
||||||
export interface RouteFeatureProps {
|
export interface RouteFeatureProps {
|
||||||
planeId: string;
|
planeId: string;
|
||||||
}
|
}
|
||||||
@@ -54,13 +60,29 @@ export function planesGeoJson(
|
|||||||
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 speedByModel = new Map(state.catalog.map((e) => [e.model, e.speedKmh]));
|
||||||
|
const clockMs = state.simState.clockMs;
|
||||||
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;
|
let lon = p.lon;
|
||||||
if (flying && p.destIcao) {
|
// v1.12.0: the marker's phase label — same legPhases composition the engine uses, derived from
|
||||||
|
// departureSimTime + clockMs (both in the snapshot) so no extra state is needed.
|
||||||
|
let phaseLabel = '';
|
||||||
|
if (flying && p.destIcao && p.departureSimTime != null) {
|
||||||
const from = airportsByIcao.get(p.locationIcao);
|
const from = airportsByIcao.get(p.locationIcao);
|
||||||
const to = airportsByIcao.get(p.destIcao);
|
const to = airportsByIcao.get(p.destIcao);
|
||||||
|
const speedKmh = speedByModel.get(p.model);
|
||||||
if (from && to) lon = unwrapLon(p.lon, from.lon);
|
if (from && to) lon = unwrapLon(p.lon, from.lon);
|
||||||
|
if (from && to && speedKmh !== undefined) {
|
||||||
|
phaseLabel = PHASE_LABELS[legPhaseAt(
|
||||||
|
p.departureSimTime,
|
||||||
|
haversineKm(from.lat, from.lon, to.lat, to.lon),
|
||||||
|
speedKmh,
|
||||||
|
p.locationIcao,
|
||||||
|
p.destIcao,
|
||||||
|
clockMs,
|
||||||
|
)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
@@ -71,6 +93,7 @@ export function planesGeoJson(
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
fuelText: formatFuel(p.fuel, p.maxFuel),
|
fuelText: formatFuel(p.fuel, p.maxFuel),
|
||||||
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
|
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
|
||||||
|
phaseLabel,
|
||||||
flying,
|
flying,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-9
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => {
|
it('documents exactly the eighteen public endpoints (4 read + 8 player actions + 4 auth + 2 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', // v0.71.0 — admin panel
|
||||||
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
||||||
@@ -20,6 +20,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
|
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
]);
|
]);
|
||||||
@@ -76,6 +77,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
@@ -85,12 +87,14 @@ describe('OpenAPI spec', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
||||||
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
||||||
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
||||||
|
expect(snap.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
|
||||||
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
||||||
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
||||||
expect(snap.fuelPricePerL.type).toBe('number');
|
expect(snap.fuelPricePerL.type).toBe('number');
|
||||||
@@ -127,6 +131,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'lon',
|
'lon',
|
||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
|
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
|
||||||
|
'ownerName', // v1.4.0 (ISS-112) — owner's username
|
||||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
'status',
|
'status',
|
||||||
@@ -136,6 +142,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(pkg).sort()).toEqual([
|
expect(Object.keys(pkg).sort()).toEqual([
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
'cargoType',
|
'cargoType',
|
||||||
|
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
'deadlineSimTime',
|
'deadlineSimTime',
|
||||||
'destIcao',
|
'destIcao',
|
||||||
@@ -184,16 +191,17 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error shapes per endpoint.
|
// Error shapes per endpoint. v1.2.0 (ISS-109): every plane-targeted action also documents 403
|
||||||
|
// (the session player does not own the plane); buy-plane has no plane target, so it gains none.
|
||||||
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
||||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
|
||||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
|
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '403', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
|
||||||
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '403', '404']); // v0.25.0
|
||||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['403', '404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||||
|
|
||||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
|
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
|
||||||
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
|
|||||||
+92
-1
@@ -110,6 +110,10 @@ export const openApiSpec = {
|
|||||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown package id or plane id',
|
description: 'Unknown package id or plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -136,6 +140,10 @@ export const openApiSpec = {
|
|||||||
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
|
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own the bound plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown package id',
|
description: 'Unknown package id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -174,6 +182,10 @@ export const openApiSpec = {
|
|||||||
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -213,6 +225,10 @@ export const openApiSpec = {
|
|||||||
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -252,6 +268,10 @@ export const openApiSpec = {
|
|||||||
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
|
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -312,6 +332,10 @@ export const openApiSpec = {
|
|||||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -337,6 +361,10 @@ export const openApiSpec = {
|
|||||||
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
|
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
|
403: {
|
||||||
|
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
@@ -348,6 +376,37 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// v1.1.0 — multiplayer step 3 (ISS-107): self-service account creation.
|
||||||
|
'/api/register': {
|
||||||
|
post: {
|
||||||
|
summary: 'Create an account',
|
||||||
|
description:
|
||||||
|
'Register a new local account (v1.1.0, ISS-107): username 3–20 chars of [a-z0-9_-] (case-insensitive input, stored lowercase), password at least 8 chars — else 400; 409 when the username is taken. Creates the users row (scrypt) AND a players row with the starter economy (the exact values a fresh world seeds: €20,000 cash, reputation 10). Mints a session immediately and returns it, so no second round-trip is needed.',
|
||||||
|
operationId: 'register',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/RegisterRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Registered — new session id + the user (same shape as login)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Invalid body (not an object, unknown field, bad username charset/length, or password shorter than 8 chars)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Username is already taken',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
|
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
|
||||||
'/api/login': {
|
'/api/login': {
|
||||||
post: {
|
post: {
|
||||||
@@ -648,6 +707,11 @@ export const openApiSpec = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/PlaneState' },
|
items: { $ref: '#/components/schemas/PlaneState' },
|
||||||
},
|
},
|
||||||
|
players: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Leaderboard rows (v1.6.0, ISS-115) — one per player in the world.',
|
||||||
|
items: { $ref: '#/components/schemas/PlayerSummary' },
|
||||||
|
},
|
||||||
packages: {
|
packages: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/PackageState' },
|
items: { $ref: '#/components/schemas/PackageState' },
|
||||||
@@ -660,7 +724,7 @@ export const openApiSpec = {
|
|||||||
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
||||||
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
||||||
},
|
},
|
||||||
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
required: ['account', 'simState', 'planes', 'players', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
||||||
},
|
},
|
||||||
CatalogEntry: {
|
CatalogEntry: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -691,6 +755,18 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['clockMs'],
|
required: ['clockMs'],
|
||||||
},
|
},
|
||||||
|
PlayerSummary: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'One leaderboard row (v1.6.0, ISS-115) — a player\'s economy plus how many planes they own.',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
username: { type: 'string' },
|
||||||
|
cash: { type: 'number' },
|
||||||
|
reputation: { type: 'number' },
|
||||||
|
planeCount: { type: 'integer', description: 'Number of planes in the world owned by this player.' },
|
||||||
|
},
|
||||||
|
required: ['id', 'username', 'cash', 'reputation', 'planeCount'],
|
||||||
|
},
|
||||||
LoadPackageRequest: {
|
LoadPackageRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -733,6 +809,15 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['planeId'],
|
required: ['planeId'],
|
||||||
},
|
},
|
||||||
|
// v1.1.0 — multiplayer step 3 (ISS-107).
|
||||||
|
RegisterRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string', description: '3–20 chars of [a-z0-9_-]; input is lowercased before validation.' },
|
||||||
|
password: { type: 'string', description: 'Plaintext, at least 8 chars — stored as a scrypt digest server-side.' },
|
||||||
|
},
|
||||||
|
required: ['username', 'password'],
|
||||||
|
},
|
||||||
// v0.71.0 — admin panel auth + DB tooling schemas.
|
// v0.71.0 — admin panel auth + DB tooling schemas.
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -866,6 +951,8 @@ export const openApiSpec = {
|
|||||||
description:
|
description:
|
||||||
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||||||
},
|
},
|
||||||
|
ownerId: { type: 'string', description: 'Owner\'s players.id (v1.4.0, ISS-112) — the snapshot carries ALL planes in the shared world.' },
|
||||||
|
ownerName: { type: 'string', description: 'Owner\'s username (v1.4.0, ISS-112).' },
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -883,6 +970,8 @@ export const openApiSpec = {
|
|||||||
'queue',
|
'queue',
|
||||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
|
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
|
||||||
|
'ownerName', // v1.4.0 (ISS-112) — owner's username
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PackageState: {
|
PackageState: {
|
||||||
@@ -906,6 +995,7 @@ export const openApiSpec = {
|
|||||||
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.' },
|
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.' },
|
||||||
|
carrierName: { type: ['string', 'null'], description: 'Carrier name (v1.4.0, ISS-112) — players.username of the carrying plane\'s owner via planeId → planes.owner_id; null when unassigned.' },
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -923,6 +1013,7 @@ export const openApiSpec = {
|
|||||||
'planeId',
|
'planeId',
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
||||||
|
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+185
-23
@@ -5,13 +5,13 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { openGameDb } from '@/lib/db/client';
|
import { openGameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { 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 { 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 { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
@@ -231,7 +231,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
const { inst, km, type } = await setupFlight('deliver-early.db');
|
const { inst, km, type } = await setupFlight('deliver-early.db');
|
||||||
const playerBefore = (await getLocalPlayer(inst));
|
const playerBefore = (await getLocalPlayer(inst));
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO) - 1; // one ms before arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(0);
|
expect(await deliverPackages(inst, clock)).toBe(0);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -252,7 +252,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -298,7 +298,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
|
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO);
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const player = (await getLocalPlayer(inst));
|
const player = (await getLocalPlayer(inst));
|
||||||
@@ -317,7 +317,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const player = (await getLocalPlayer(inst));
|
const player = (await getLocalPlayer(inst));
|
||||||
@@ -358,7 +358,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -383,7 +383,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
|
const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
|
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
|
||||||
@@ -429,7 +429,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -445,7 +445,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
it('grounds as before when the queue is empty', async () => {
|
it('grounds as before when the queue is empty', async () => {
|
||||||
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
|
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||||
@@ -473,7 +473,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.set({ queue: JSON.stringify(['FGSL']) })
|
.set({ queue: JSON.stringify(['FGSL']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -490,13 +490,85 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
|
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
|
||||||
expect(plane.destIcao).toBeNull();
|
expect(plane.destIcao).toBeNull();
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Per-owner economy (v1.3.0, ISS-110) ---
|
||||||
|
|
||||||
|
it('pays cash + reputation to the plane OWNER, not the local player', async () => {
|
||||||
|
const { inst, km } = await setupFlight('deliver-owner.db');
|
||||||
|
// A second player owns the starter — distinct economy so any leak into 'local' is visible.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 5 });
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ ownerId: 'player-b' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
|
||||||
|
const bBefore = (await getPlayer(inst, 'player-b'));
|
||||||
|
await inst.db.insert(schema.packages).values([
|
||||||
|
{ id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const bAfter = (await getPlayer(inst, 'player-b'));
|
||||||
|
expect(bAfter.cash).toBeCloseTo(bBefore.cash + 5_000, 9); // the owner was paid…
|
||||||
|
expect(bAfter.reputation).toBeCloseTo(Math.round((bBefore.reputation + reputationGainFor(km)) * 100) / 100, 9); // …with the distance-based rep gain
|
||||||
|
const localAfter = (await getPlayer(inst, LOCAL_PLAYER.id));
|
||||||
|
expect(localAfter.cash).toBe(localBefore.cash); // the local player is untouched…
|
||||||
|
expect(localAfter.reputation).toBe(localBefore.reputation); // …in both directions
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debits the auto top-up from the plane OWNER, not the local player (ISS-082 × ISS-110)', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('chain-owner.db');
|
||||||
|
// Owner B funds the chain: distinct cash + rep so both the debit and the discount are attributable.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 50_000, reputation: 10 });
|
||||||
|
|
||||||
|
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD → lands empty; LEMD→EHAM needs a top-up.
|
||||||
|
const w = weatherFor(FROM);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']), ownerId: 'player-b' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
|
||||||
|
const bBefore = (await getPlayer(inst, 'player-b'));
|
||||||
|
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
// Expected cost from the SAME helpers the engine uses — computed with B's rep discount.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
|
||||||
|
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
|
||||||
|
const wTo = weatherFor(TO);
|
||||||
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
|
||||||
|
const nextRequiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * fNext,
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
|
||||||
|
);
|
||||||
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||||
|
const liters = Math.max(0, target - landedFuel);
|
||||||
|
expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left
|
||||||
|
const rate = repRate(bBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying'); // B's cash funded the chain…
|
||||||
|
expect(plane.destIcao).toBe('EHAM');
|
||||||
|
const bAfter = (await getPlayer(inst, 'player-b'));
|
||||||
|
expect(bAfter.cash).toBeCloseTo(bBefore.cash - expectedCost, 9); // …debited from B…
|
||||||
|
expect((await getPlayer(inst, LOCAL_PLAYER.id)).cash).toBe(localBefore.cash); // …not local
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||||
@@ -522,7 +594,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
|||||||
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||||
const { inst, km } = await setupFlight('wear-leg.db');
|
const { inst, km } = await setupFlight('wear-leg.db');
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
|
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
|
||||||
@@ -535,18 +607,19 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
|||||||
.set({ health: WEAR_PER_KM * km / 2 })
|
.set({ health: WEAR_PER_KM * km / 2 })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
|
||||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.health).toBe(0); // clamped — never negative…
|
expect(plane.health).toBe(0); // clamped — never negative…
|
||||||
|
|
||||||
// …and a second leg keeps it pinned at 0.
|
// …and a second leg keeps it pinned at 0.
|
||||||
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||||
|
const arr1 = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // leg 1's exact arrival (v1.12.0 phases)
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: arr1, fuel: type.maxFuel })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(arr1, nextKm, type.speedKmh, TO, 'EHAM'))).toBe(1);
|
||||||
plane = (await inst.db.select().from(schema.planes))[0];
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.health).toBe(0); // still clamped after the second leg
|
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||||
});
|
});
|
||||||
@@ -560,7 +633,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
|||||||
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
|
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.health).toBe(0); // broken by this landing's wear…
|
expect(plane.health).toBe(0); // broken by this landing's wear…
|
||||||
@@ -670,6 +743,32 @@ describe('grounded fleet freezes the world clock (#135)', () => {
|
|||||||
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('N-player fleet: one owner airborne keeps the clock running; all owners grounded freezes it (ISS-113)', async () => {
|
||||||
|
const inst = await freshDb('nplayer-clock.db');
|
||||||
|
// A second player with their own GROUNDED plane — the world now has two owners.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-b', ownerId: 'player-b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A (local) takes off while B stays grounded → the world advances — one owner flying is enough…
|
||||||
|
await makeWorldActive(inst);
|
||||||
|
vi.setSystemTime(T0 + 1_000);
|
||||||
|
const sim = await tickOnce(inst);
|
||||||
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // …at the fixed world rate
|
||||||
|
|
||||||
|
// …and grounding A's plane (B was already grounded) freezes the whole world.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'available', destIcao: null, departureSimTime: null })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
vi.setSystemTime(T0 + 2_000); // a full second of real idleness with every owner grounded
|
||||||
|
const frozen = await tickOnce(inst);
|
||||||
|
|
||||||
|
expect(frozen.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // frozen — no advance…
|
||||||
|
expect(frozen.lastRealTickMs).toBe(T0 + 2_000); // …but the real-time anchor keeps tracking
|
||||||
|
});
|
||||||
|
|
||||||
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
|
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
|
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).
|
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
|
||||||
@@ -783,7 +882,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
|||||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await inst.db.insert(schema.planes).values({
|
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-2', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
|
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
|
||||||
@@ -828,7 +927,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
|||||||
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
|
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
|
||||||
// never lands here.
|
// 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', ownerId: 'local', 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,
|
||||||
});
|
});
|
||||||
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
||||||
@@ -839,10 +938,73 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
|||||||
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
|
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(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
|
||||||
|
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
|
||||||
|
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
|
||||||
|
// against the math, not against itself.
|
||||||
|
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
|
||||||
|
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
|
||||||
|
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
|
||||||
|
return Math.round(
|
||||||
|
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
|
||||||
|
const inst = await freshDb('pool-shared-rep.db');
|
||||||
|
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
|
||||||
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
for (const p of openRows) {
|
||||||
|
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
|
||||||
|
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
it('MAX picks the local row when local leads — symmetric to the player-b case (v1.5.0)', async () => {
|
||||||
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
const inst = await freshDb('pool-shared-rep-local.db');
|
||||||
function travelMs(km: number, speedKmh: number): number {
|
// Local is far ahead of b's starting rep — a "prefer non-local" implementation would pick b and fail here.
|
||||||
return (km / speedKmh) * 3_600_000;
|
await updateLocalPlayerEconomy(inst, { reputation: 60 });
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||||
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
for (const p of openRows) {
|
||||||
|
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
|
||||||
|
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to b's starting rep
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
|
||||||
|
const inst = await freshDb('pool-single-rep.db');
|
||||||
|
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
|
||||||
|
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
|
||||||
|
await updateLocalPlayerEconomy(inst, { reputation: 60 });
|
||||||
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||||
|
|
||||||
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
|
for (const p of openRows) {
|
||||||
|
expect(p.reward).toBe(await expectedRewardAt(p, 60));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -1,7 +1,7 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { eq, sql } 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 { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
@@ -9,7 +9,7 @@ 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 { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
||||||
import { broadcast } from '@/lib/sse';
|
import { broadcast, subscribedPlayerIds } from '@/lib/sse';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
JOB_LIFETIME_MS,
|
JOB_LIFETIME_MS,
|
||||||
@@ -228,7 +228,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
if (!type || !from || !to) continue; // no route info — leave alone
|
if (!type || !from || !to) continue; // no route info — leave alone
|
||||||
|
|
||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
// v1.12.0: the gate includes the takeoff/landing phases (weather-scaled) — see legPhases.
|
||||||
|
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao)) continue;
|
||||||
|
|
||||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
||||||
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||||||
@@ -251,12 +252,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
for (const pk of onBoard) {
|
for (const pk of onBoard) {
|
||||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
||||||
// Fresh player read per award so multiple deliveries in one tick all count.
|
// v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
|
||||||
const fresh = await getLocalPlayer(inst);
|
// multiple deliveries in one tick all count.
|
||||||
|
const fresh = await getPlayer(inst, p.ownerId);
|
||||||
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
|
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
|
||||||
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
|
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
|
||||||
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
||||||
await updateLocalPlayerEconomy(inst, {
|
await updatePlayerEconomy(inst, p.ownerId, {
|
||||||
cash: fresh.cash + pk.reward,
|
cash: fresh.cash + pk.reward,
|
||||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
|
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
|
||||||
});
|
});
|
||||||
@@ -306,14 +308,15 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
|
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
|
||||||
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||||
const liters = Math.max(0, target - landedFuel);
|
const liters = Math.max(0, target - landedFuel);
|
||||||
// Fresh player read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
|
// v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
|
||||||
const fresh = await getLocalPlayer(inst);
|
// delivery awards above) so a top-up in this tick sees current cash.
|
||||||
|
const fresh = await getPlayer(inst, p.ownerId);
|
||||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
if (cost === 0 || fresh.cash >= cost) {
|
if (cost === 0 || fresh.cash >= cost) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
chainedTo = nextDest;
|
chainedTo = nextDest;
|
||||||
topUpFuel = target;
|
topUpFuel = target;
|
||||||
if (cost > 0) await updateLocalPlayerEconomy(inst, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,8 +385,11 @@ export async function spawnPoolPackages(
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||||
// v0.38.0 (ISS-072): spawned jobs carry the local player's CURRENT reputation quality — read once per spawn batch.
|
// v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
|
||||||
|
// agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
|
||||||
|
// player instead of the seeded local row.
|
||||||
|
const reputation = await getSharedWorldReputation(inst);
|
||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const originIcao of pool) {
|
for (const originIcao of pool) {
|
||||||
@@ -413,7 +419,7 @@ export async function spawnPoolPackages(
|
|||||||
clockMs,
|
clockMs,
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
createdRealMs: real,
|
createdRealMs: real,
|
||||||
reputation: player.reputation,
|
reputation,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
openByBand[bandIdx]++;
|
openByBand[bandIdx]++;
|
||||||
@@ -431,13 +437,22 @@ export async function spawnPoolPackages(
|
|||||||
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
// 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
|
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||||
// other clients too.
|
// other clients too.
|
||||||
|
// v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's
|
||||||
|
// player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only,
|
||||||
|
// so every client sees its own account row in every push. Same shared projection as GET /api/state
|
||||||
|
// (getStateSnapshot + sseFrame), so pushed frames can never drift from polled ones. A session whose
|
||||||
|
// players row vanished mid-stream is skipped per-tick with a log — the others keep flowing.
|
||||||
async function broadcastState(): Promise<void> {
|
async function broadcastState(): Promise<void> {
|
||||||
|
const frames = new Map<string, string>();
|
||||||
|
for (const playerId of subscribedPlayerIds()) {
|
||||||
try {
|
try {
|
||||||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId))));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('flight: state broadcast failed', err);
|
console.error('flight: state broadcast failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (frames.size > 0) broadcast(frames);
|
||||||
|
}
|
||||||
|
|
||||||
export function tickNow(): Promise<schema.SimStateRow> {
|
export function tickNow(): Promise<schema.SimStateRow> {
|
||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
|
|||||||
+94
-24
@@ -1,13 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
|
import { weatherFor, type AirportWeather, type WeatherCondition } from '@/lib/weather';
|
||||||
import {
|
import {
|
||||||
arrivalTimeFor,
|
arrivalTimeFor,
|
||||||
|
BASE_PHASE_MS,
|
||||||
burnRateConstant,
|
burnRateConstant,
|
||||||
FUEL_DENSITY_KG_PER_L,
|
FUEL_DENSITY_KG_PER_L,
|
||||||
fuelAtWeighted,
|
fuelAtWeighted,
|
||||||
guaranteedRangeKm,
|
guaranteedRangeKm,
|
||||||
|
legPhases,
|
||||||
|
legPhaseAt,
|
||||||
|
MAX_PHASE_FACTOR,
|
||||||
MAX_WIND_BURN_FACTOR,
|
MAX_WIND_BURN_FACTOR,
|
||||||
maxRangeKm,
|
maxRangeKm,
|
||||||
|
phaseFactor,
|
||||||
positionAt,
|
positionAt,
|
||||||
progressAt,
|
progressAt,
|
||||||
requiredFuelFor,
|
requiredFuelFor,
|
||||||
@@ -38,36 +44,98 @@ describe('travelDurationMs / arrivalTimeFor', () => {
|
|||||||
expect(() => travelDurationMs(100, 0)).toThrow();
|
expect(() => travelDurationMs(100, 0)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('arrival is departure + duration', () => {
|
it('arrival is departure plus all three segments (v1.12.0)', () => {
|
||||||
const arrival = arrivalTimeFor(DEP, KM, SPEED);
|
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||||
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
|
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBe(DEP + ph.totalMs);
|
||||||
expect(arrival).toBeGreaterThan(DEP);
|
// The cruise part is the old pure duration — phases only add on top.
|
||||||
|
expect(ph.cruiseMs).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
|
||||||
|
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBeGreaterThan(DEP + ph.cruiseMs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('progressAt', () => {
|
describe('phaseFactor / legPhases (v1.12.0)', () => {
|
||||||
const duration = travelDurationMs(KM, SPEED);
|
const mk = (condition: WeatherCondition): AirportWeather => ({ condition, windDirDeg: 90, windKt: 15, visibilityM: 8_000 });
|
||||||
|
|
||||||
it('is 0 before departure and at departure', () => {
|
it('MAX_PHASE_FACTOR is phaseFactor\'s actual maximum over the weather domain', () => {
|
||||||
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
|
// Fog is the worst condition — the constant must track the factor function, not a copy.
|
||||||
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
|
expect(MAX_PHASE_FACTOR).toBe(phaseFactor(mk('Fog')));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is linear in the middle of the flight', () => {
|
it('stays within [1, MAX_PHASE_FACTOR] and is monotone in condition severity', () => {
|
||||||
|
for (const c of ['Clear', 'Cloudy', 'Rain', 'Fog'] as const) {
|
||||||
|
const f = phaseFactor(mk(c));
|
||||||
|
expect(f).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(f).toBeLessThanOrEqual(MAX_PHASE_FACTOR);
|
||||||
|
}
|
||||||
|
expect(phaseFactor(mk('Clear'))).toBe(1); // clear skies stay at 1×
|
||||||
|
expect(phaseFactor(mk('Cloudy'))).toBeGreaterThan(phaseFactor(mk('Clear')));
|
||||||
|
expect(phaseFactor(mk('Rain'))).toBeGreaterThan(phaseFactor(mk('Cloudy')));
|
||||||
|
expect(phaseFactor(mk('Fog'))).toBe(MAX_PHASE_FACTOR); // worst condition is exactly 2×
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a deterministic function of the ICAO via weatherFor', () => {
|
||||||
|
for (const icao of ['EDDF', 'LEMD', 'EHAM', 'KSMF']) {
|
||||||
|
expect(phaseFactor(weatherFor(icao))).toBe(phaseFactor(weatherFor(icao)));
|
||||||
|
}
|
||||||
|
// KSMF is Clear in the deterministic generator — a real ICAO pinned at exactly 1×.
|
||||||
|
expect(phaseFactor(weatherFor('KSMF'))).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takeoff scales on ORIGIN weather, landing on DESTINATION weather', () => {
|
||||||
|
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||||
|
expect(ph.takeoffMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('EDDF')));
|
||||||
|
expect(ph.landingMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('LEMD')));
|
||||||
|
// Non-vacuous: the two airports really do differ (Rain vs Cloudy), so a swapped
|
||||||
|
// origin/dest would fail these pins.
|
||||||
|
expect(phaseFactor(weatherFor('EDDF'))).not.toBe(phaseFactor(weatherFor('LEMD')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('total is the sum of the three segments and exceeds the old pure duration', () => {
|
||||||
|
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||||
|
expect(ph.totalMs).toBe(ph.takeoffMs + ph.cruiseMs + ph.landingMs);
|
||||||
|
expect(ph.totalMs).toBeGreaterThan(travelDurationMs(KM, SPEED));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legPhaseAt walks takeoff → cruise → landing over the leg', () => {
|
||||||
|
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||||
|
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP)).toBe('takeoff');
|
||||||
|
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs - 1)).toBe('takeoff');
|
||||||
|
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs)).toBe('cruise');
|
||||||
|
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs + ph.cruiseMs - 1)).toBe('cruise');
|
||||||
|
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.totalMs - 1)).toBe('landing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progressAt (piecewise, v1.12.0)', () => {
|
||||||
|
const ORIGIN = 'EDDF';
|
||||||
|
const DEST = 'LEMD';
|
||||||
|
const ph = legPhases(KM, SPEED, ORIGIN, DEST);
|
||||||
|
|
||||||
|
it('is 0 before departure and throughout the takeoff hold at the origin', () => {
|
||||||
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP - 1)).toBe(0);
|
||||||
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs / 2)).toBe(0);
|
||||||
|
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs - 1));
|
||||||
|
expect(p).toEqual({ lat: FROM.lat, lon: FROM.lon }); // still at the gate — no crawl along the route
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linear over the cruise window', () => {
|
||||||
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
|
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
|
||||||
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
|
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
|
||||||
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs / 2)).toBeCloseTo(0.5, 9);
|
||||||
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs * 0.25)).toBeCloseTo(0.25, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is exactly 1 at arrival and stays 1 after', () => {
|
it('is exactly 1 at the start of the landing hold and stays 1 through arrival', () => {
|
||||||
const arrival = DEP + duration;
|
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs));
|
||||||
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
|
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // parked at the destination gate during landing
|
||||||
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
|
const arrival = DEP + ph.totalMs;
|
||||||
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival)).toBe(1);
|
||||||
|
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival + 10_000)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a zero-distance leg is complete immediately', () => {
|
it('a zero-distance leg has no cruise to progress through', () => {
|
||||||
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
|
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP - 5)).toBe(0); // before departure
|
||||||
|
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP + 1)).toBe(1); // complete once departed
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,12 +228,14 @@ describe('weighted burn model (v0.16.0)', () => {
|
|||||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
|
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('composes with progressAt for a just-before-arrival snapshot', () => {
|
it('composes with progressAt for a just-before-arrival snapshot (v1.12.0)', () => {
|
||||||
const duration = travelDurationMs(KM, SPEED);
|
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||||
const clock = DEP + duration - 1; // one ms before arrival
|
const clock = DEP + ph.totalMs - 1; // one ms before arrival — inside the landing hold
|
||||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
|
const progress = progressAt(DEP, KM, SPEED, 'EDDF', 'LEMD', clock);
|
||||||
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
|
expect(progress).toBe(1); // cruise is done: the plane holds at the destination gate…
|
||||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
|
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progress);
|
||||||
|
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // …so it IS at the gate (v1.12.0 piecewise)
|
||||||
|
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progress)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+83
-8
@@ -3,6 +3,8 @@
|
|||||||
// distance in game time without losing accuracy. All inputs are explicit; there
|
// distance in game time without losing accuracy. All inputs are explicit; there
|
||||||
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
|
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
|
||||||
|
|
||||||
|
import { weatherFor, type AirportWeather } from '@/lib/weather';
|
||||||
|
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
const toDeg = (r: number) => (r * 180) / Math.PI;
|
const toDeg = (r: number) => (r * 180) / Math.PI;
|
||||||
|
|
||||||
@@ -12,22 +14,95 @@ export function travelDurationMs(distanceKm: number, speedKmh: number): number {
|
|||||||
return (distanceKm / speedKmh) * 3_600_000;
|
return (distanceKm / speedKmh) * 3_600_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sim-clock time at which the leg completes.
|
// --- Takeoff & landing phases (v1.12.0) ---
|
||||||
export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number {
|
//
|
||||||
return departureSimTime + travelDurationMs(distanceKm, speedKmh);
|
// Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the
|
||||||
|
// destination, on top of the pure cruise time. Weather at the relevant airport stretches each
|
||||||
|
// phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases
|
||||||
|
// burn no fuel — they are pure time, so the weighted-burn model is untouched.
|
||||||
|
|
||||||
|
// One phase's duration in clear weather: 10 minutes of sim time.
|
||||||
|
export const BASE_PHASE_MS = 600_000;
|
||||||
|
|
||||||
|
// Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the
|
||||||
|
// condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a
|
||||||
|
// function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent
|
||||||
|
// signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR.
|
||||||
|
export function phaseFactor(w: AirportWeather): number {
|
||||||
|
const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3;
|
||||||
|
return 1 + step / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before
|
// Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition).
|
||||||
// departure and 1 after arrival. A zero-distance leg is complete immediately.
|
export const MAX_PHASE_FACTOR = 2;
|
||||||
|
|
||||||
|
export interface LegPhases {
|
||||||
|
takeoffMs: number; // origin weather
|
||||||
|
cruiseMs: number; // pure cruise — the old travelDurationMs value
|
||||||
|
landingMs: number; // destination weather
|
||||||
|
totalMs: number; // takeoff + cruise + landing
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION
|
||||||
|
// weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the
|
||||||
|
// two ICAs recomputes exactly what the engine used — no stored state needed.
|
||||||
|
export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases {
|
||||||
|
const cruiseMs = travelDurationMs(distanceKm, speedKmh);
|
||||||
|
const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao));
|
||||||
|
const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao));
|
||||||
|
return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0).
|
||||||
|
export function arrivalTimeFor(
|
||||||
|
departureSimTime: number,
|
||||||
|
distanceKm: number,
|
||||||
|
speedKmh: number,
|
||||||
|
originIcao: string,
|
||||||
|
destIcao: string,
|
||||||
|
): number {
|
||||||
|
return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before
|
||||||
|
// departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear
|
||||||
|
// over the cruise window, 1 while it holds at the destination during landing. That keeps
|
||||||
|
// positionAt/fuel pinned to each airport during its phase instead of crawling along the whole
|
||||||
|
// route. A zero-distance leg has no cruise: complete once departed.
|
||||||
export function progressAt(
|
export function progressAt(
|
||||||
departureSimTime: number,
|
departureSimTime: number,
|
||||||
distanceKm: number,
|
distanceKm: number,
|
||||||
speedKmh: number,
|
speedKmh: number,
|
||||||
|
originIcao: string,
|
||||||
|
destIcao: string,
|
||||||
clockMs: number,
|
clockMs: number,
|
||||||
): number {
|
): number {
|
||||||
const duration = travelDurationMs(distanceKm, speedKmh);
|
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||||||
if (duration <= 0) return 1;
|
const t = clockMs - departureSimTime;
|
||||||
return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration));
|
if (t <= 0) return 0;
|
||||||
|
if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise
|
||||||
|
if (t >= ph.totalMs) return 1;
|
||||||
|
if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff
|
||||||
|
if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing
|
||||||
|
return (t - ph.takeoffMs) / ph.cruiseMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegPhase = 'takeoff' | 'cruise' | 'landing';
|
||||||
|
|
||||||
|
// Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0).
|
||||||
|
export function legPhaseAt(
|
||||||
|
departureSimTime: number,
|
||||||
|
distanceKm: number,
|
||||||
|
speedKmh: number,
|
||||||
|
originIcao: string,
|
||||||
|
destIcao: string,
|
||||||
|
clockMs: number,
|
||||||
|
): LegPhase {
|
||||||
|
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||||||
|
const t = clockMs - departureSimTime;
|
||||||
|
if (t < ph.takeoffMs) return 'takeoff';
|
||||||
|
if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise';
|
||||||
|
return 'landing';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]
|
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]
|
||||||
|
|||||||
+38
-18
@@ -1,45 +1,65 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { broadcast, subscribe } from './sse';
|
import { broadcast, subscribedPlayerIds, subscribe } from './sse';
|
||||||
|
|
||||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
|
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
|
||||||
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
// leak across tests in this file (module-level state), which is fine: each test asserts on its own
|
||||||
// each test only asserts on its own arrays.
|
// arrays and uses distinct player ids where isolation matters. v1.4.0 (ISS-112): keyed by PLAYER ID
|
||||||
|
// — a frame for one player must never reach another player's streams.
|
||||||
|
|
||||||
describe('lib/sse', () => {
|
describe('lib/sse', () => {
|
||||||
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
|
it('delivers broadcasts to subscribers of the SAME player only, and stops after unsubscribe', () => {
|
||||||
const a: string[] = [];
|
const a: string[] = [];
|
||||||
const b: string[] = [];
|
const b: string[] = []; // same player as a — both get its frames
|
||||||
const unsubA = subscribe((t) => a.push(t));
|
const c: string[] = []; // different player — never sees their frames
|
||||||
const unsubB = subscribe((t) => b.push(t));
|
const unsubA = subscribe('p-a', (t) => a.push(t));
|
||||||
|
const unsubB = subscribe('p-a', (t) => b.push(t));
|
||||||
|
const unsubC = subscribe('p-c', (t) => c.push(t));
|
||||||
|
|
||||||
broadcast('x');
|
broadcast(new Map([['p-a', 'x']]));
|
||||||
expect(a).toEqual(['x']);
|
expect(a).toEqual(['x']);
|
||||||
expect(b).toEqual(['x']);
|
expect(b).toEqual(['x']); // same group
|
||||||
|
expect(c).toEqual([]); // other player's frame never crosses over
|
||||||
|
|
||||||
unsubA();
|
unsubA();
|
||||||
broadcast('y');
|
broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
|
||||||
expect(a).toEqual(['x']); // unsubscribed — no more events
|
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||||
expect(b).toEqual(['x', 'y']);
|
expect(b).toEqual(['x', 'y']);
|
||||||
|
expect(c).toEqual(['z']);
|
||||||
unsubB();
|
unsubB();
|
||||||
|
unsubC();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a throwing subscriber does not break the others', () => {
|
it('subscribedPlayerIds lists exactly the players with live streams, dropping empty groups', () => {
|
||||||
const got: string[] = [];
|
const got: string[] = [];
|
||||||
subscribe(() => {
|
const unsub1 = subscribe('p-ids-1', (t) => got.push(t));
|
||||||
|
const unsub2 = subscribe('p-ids-1', (t) => got.push(t)); // second member of the same group
|
||||||
|
const unsub3 = subscribe('p-ids-2', (t) => got.push(t));
|
||||||
|
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
|
||||||
|
|
||||||
|
unsub1(); // p-ids-1 still has one member — id stays listed
|
||||||
|
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
|
||||||
|
unsub3(); // p-ids-2's last stream goes → its projections stop
|
||||||
|
expect(subscribedPlayerIds()).toEqual(['p-ids-1']);
|
||||||
|
unsub2();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a throwing subscriber does not break the others in its group', () => {
|
||||||
|
const got: string[] = [];
|
||||||
|
subscribe('p-boom', () => {
|
||||||
throw new Error('boom');
|
throw new Error('boom');
|
||||||
});
|
});
|
||||||
const unsub = subscribe((t) => got.push(t));
|
const unsub = subscribe('p-boom', (t) => got.push(t));
|
||||||
broadcast('z');
|
broadcast(new Map([['p-boom', 'z']]));
|
||||||
expect(got).toEqual(['z']);
|
expect(got).toEqual(['z']);
|
||||||
unsub();
|
unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unsubscribe is idempotent', () => {
|
it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
|
||||||
const got: string[] = [];
|
const got: string[] = [];
|
||||||
const unsub = subscribe((t) => got.push(t));
|
const unsub = subscribe('p-idem', (t) => got.push(t));
|
||||||
unsub();
|
unsub();
|
||||||
unsub(); // second call must not throw
|
unsub(); // second call must not throw
|
||||||
broadcast('w');
|
broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
|
||||||
expect(got).toEqual([]);
|
expect(got).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-10
@@ -1,25 +1,45 @@
|
|||||||
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
// 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
|
// 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
|
// sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
|
||||||
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
// 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
|
// 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.
|
// give each its own empty map and no push would ever be delivered. v1.4.0 (ISS-112): subscribers are
|
||||||
|
// keyed by PLAYER ID — the engine projects one snapshot per connected player and pushes each frame
|
||||||
|
// only to that player's group, so every client sees its own account row in every pushed frame.
|
||||||
|
|
||||||
type Push = (text: string) => void;
|
type Push = (text: string) => void;
|
||||||
|
|
||||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
|
||||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
|
||||||
|
|
||||||
export function subscribe(fn: Push): () => void {
|
export function subscribe(playerId: string, fn: Push): () => void {
|
||||||
subscribers.add(fn);
|
let group = subscribers.get(playerId);
|
||||||
|
if (!group) {
|
||||||
|
group = new Set();
|
||||||
|
subscribers.set(playerId, group);
|
||||||
|
}
|
||||||
|
group.add(fn);
|
||||||
return () => {
|
return () => {
|
||||||
subscribers.delete(fn);
|
const s = subscribers.get(playerId);
|
||||||
|
if (!s) return; // already unsubscribed — idempotent
|
||||||
|
s.delete(fn);
|
||||||
|
if (s.size === 0) subscribers.delete(playerId); // drop the empty group so projections stop for it
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call each subscriber safely — one dead stream must not take the others down.
|
// v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
|
||||||
export function broadcast(text: string): void {
|
// projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
|
||||||
for (const fn of [...subscribers]) {
|
export function subscribedPlayerIds(): string[] {
|
||||||
|
return [...subscribers.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call each subscriber safely — one dead stream must not take the others down. `frames` maps
|
||||||
|
// player id → pre-serialized frame text (the engine projects per player, this only fans out).
|
||||||
|
export function broadcast(frames: Map<string, string>): void {
|
||||||
|
for (const [playerId, text] of frames) {
|
||||||
|
const group = subscribers.get(playerId);
|
||||||
|
if (!group) continue; // unsubscribed between projection and push — fine
|
||||||
|
for (const fn of [...group]) {
|
||||||
try {
|
try {
|
||||||
fn(text);
|
fn(text);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -27,3 +47,4 @@ export function broadcast(text: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { StateSnapshot } from '@/lib/game/state';
|
import type { StateSnapshot } from '@/lib/game/state';
|
||||||
|
import { sessionHeaders } from './authClient';
|
||||||
|
|
||||||
// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from
|
// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from
|
||||||
// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
|
// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
|
||||||
@@ -30,7 +31,8 @@ export function useGameAction(
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// v1.1.0 (ISS-107): action endpoints are session-guarded — echo the stored id.
|
||||||
|
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ describe('fetchState', () => {
|
|||||||
|
|
||||||
await expect(fetchState()).resolves.toEqual(SNAPSHOT);
|
await expect(fetchState()).resolves.toEqual(SNAPSHOT);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' });
|
// No stored session in node → empty headers object (v1.1.0, ISS-107).
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the stored session as X-Flight-Session when one exists (v1.1.0)', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
localStorage: { getItem: (k: string) => (k === 'flight.session' ? 'sid-123' : null), removeItem: () => {} },
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await fetchState();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: { 'X-Flight-Session': 'sid-123' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws with the HTTP status on a non-2xx response', async () => {
|
it('throws with the HTTP status on a non-2xx response', async () => {
|
||||||
|
|||||||
+7
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
|
import { sessionHeaders, storedSession } from './authClient';
|
||||||
|
|
||||||
export interface GameStateResult {
|
export interface GameStateResult {
|
||||||
state: StateSnapshot | null;
|
state: StateSnapshot | null;
|
||||||
@@ -19,7 +20,8 @@ export interface GameStateResult {
|
|||||||
|
|
||||||
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
|
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
|
||||||
export async function fetchState(): Promise<StateSnapshot> {
|
export async function fetchState(): Promise<StateSnapshot> {
|
||||||
const res = await fetch('/api/state', { cache: 'no-store' });
|
// v1.1.0 (ISS-107): /api/state is session-guarded — the stored id rides along as X-Flight-Session.
|
||||||
|
const res = await fetch('/api/state', { cache: 'no-store', headers: sessionHeaders() });
|
||||||
if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
|
||||||
const snapshot = (await res.json()) as StateSnapshot;
|
const snapshot = (await res.json()) as StateSnapshot;
|
||||||
// Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what
|
// Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what
|
||||||
@@ -93,7 +95,10 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
|||||||
|
|
||||||
let source: EventSource | null = null;
|
let source: EventSource | null = null;
|
||||||
try {
|
try {
|
||||||
source = new EventSource(SSE_URL);
|
// v1.1.0 (ISS-107): EventSource cannot set custom headers, so the session id rides along as a
|
||||||
|
// query param — /api/events accepts ?session= exactly for this (requireAuthSse).
|
||||||
|
const sid = storedSession();
|
||||||
|
source = new EventSource(sid ? `${SSE_URL}?session=${encodeURIComponent(sid)}` : SSE_URL);
|
||||||
} catch {
|
} catch {
|
||||||
source = null; // no EventSource support — pure polling below
|
source = null; // no EventSource support — pure polling below
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.0.0",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user