Compare commits
1
Commits
v1.4.0
..
eef305aa27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef305aa27 |
@@ -1,61 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## v1.4.0 — 2026-09-26 18:35
|
||||
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
|
||||
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
|
||||
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
|
||||
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
|
||||
|
||||
## v1.3.0 — 2026-09-26 17:05
|
||||
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
|
||||
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
|
||||
- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths.
|
||||
|
||||
## v1.2.0 — 2026-09-26 16:25
|
||||
- **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had.
|
||||
- **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane.
|
||||
- **Mint hook default → kamilkrzywda.** The e2e specs play with the seeded world's planes by id (`plane-starter`), so the no-body mint now resolves to the existing local player that owns them after backfill — creating NO new rows; a custom username still creates a fresh account, now WITH onboarding. `e2e/auth.ts` and every spec are untouched: they keep working because the session they mint is the owner's.
|
||||
- **OpenAPI.** The seven plane-targeted action endpoints document the new 403 (buy-plane has no plane target, so it gains none); path count unchanged at eighteen — `openapi.test.ts`'s per-endpoint error-code lists updated to match.
|
||||
- **Tests.** Unit: new backfill migration test in `lib/db/client.test.ts` builds a pre-v1.2.0 file (two planes without owner_id) and verifies open() backfills both to `'local'`, does not re-seed, and is a no-op on second open (a post-migration owner change survives reopen); new ownership suite in `lib/game/actions.test.ts` — 403 for every action when the session player doesn't own the plane (both directions), per-owner last-plane guard against the world total, buyer stamped as owner with their own cash debited and local's untouched; register + mint route tests assert exactly one onboarding plane at EDDF owned by the new account. All 74 existing action call sites in `actions.test.ts` now pass `'local'`; no e2e spec changes needed (the minted session is the owner's).
|
||||
|
||||
## v1.1.0 — 2026-09-26 14:39
|
||||
- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook.
|
||||
- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip.
|
||||
- **Sign-in screen replaces the shell until authenticated.** New `components/SignInScreen.tsx`: full-screen dark panel with two forms (sign in / create account) sharing one submit path; errors surface in a single `role=alert` line. `app/page.tsx` gates on it: stored session → `/api/me` check on mount (stale id cleared, network error falls through to sign-in), then the game shell mounts only when authenticated — so an unauthenticated browser never polls `/api/state`. New client module `lib/authClient.ts` owns the localStorage key (`flight.session`) and builds the `X-Flight-Session` header; every app fetch (state poll, SSE URL, action POSTs, fly/queue in Map) now carries it. Admin panel logout drops back to the sign-in screen via a new `onLogout` prop (page → Map → AdminPanel).
|
||||
- **E2E session hook + spec updates.** New test-only `POST /api/test/mint-player-session` (FLIGHT_TEST_HOOKS=1, 404 elsewhere — like its `/api/test/*` siblings): creates the users+players rows for a username if missing and mints a session, idempotent across container restarts since the CI DB volume persists. `e2e/auth.ts` mints + applies it per test (context extraHTTPHeaders for spec-side requests, localStorage init script for the app's own fetches); every guarded-route spec authenticates in a beforeEach; events.spec uses the real client path (`?session=`); api.spec/openapi tests track the new eighteenth endpoint. Admin spec reworked: unauthenticated visitors see the sign-in screen (no shell), wrong credentials error there, and logout returns to it.
|
||||
- **Tests.** Unit: new `lib/auth.test.ts` (hash round-trip; 401 on missing/unknown/expired session; admin→local player join; 500 on missing players row), register route tests (validation matrix, shared-id rows + starter economy + minted session, case-insensitive 409) and mint-hook tests (gate off → 404 even with CI's FLIGHT_TEST_HOOKS=1 env — stubbed explicitly, default-username creation, idempotent re-mint, bad usernames). All existing state/events/action route tests now mint a session for the seeded admin user in beforeAll. OpenAPI: `/api/register` documented (RegisterRequest schema; 200/400/409) — spec is now eighteen paths.
|
||||
|
||||
## v1.0.0 — 2026-09-24 14:20
|
||||
- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before.
|
||||
- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas.
|
||||
- **Tests.** Unit: fresh DB now asserts the seeded local player row (`local`/`local`/`kamilkrzywda`, starting cash/reputation) AND that no `account` table exists; new dedicated migration test builds a minimal legacy file containing only an `account` table and verifies open() copies its row into the local player, drops `account`, and is a no-op on second open. All existing suites (actions, engine, client, buy/sell/abandon routes) now read/write through the helper; the admin rows CRUD test's PK-less-table example moves from `account` to `sim_state` (the only remaining PK-less table), and the admin tables-list expectation swaps `account` → `players`. No new e2e spec — behavior is byte-identical and CI's existing suite covers the UI paths; a fresh-stack boot exercises the new seed path.
|
||||
|
||||
## v0.73.0 — 2026-09-24 12:24
|
||||
- **New-package toasts removed.** The client-side snapshot diff in `lib/deriveEvents.ts` no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged.
|
||||
|
||||
## v0.72.0 — 2026-09-24 11:44
|
||||
- **Admin login fixed.** The seeded admin's scrypt digest in `lib/db/client.ts` was computed with different parameters than the verifier (`verifyPassword()` passes the salt as a plain string to `scryptSync(pw, saltHex, 32)`), so real logins failed with 401 "invalid credentials" since v0.71.0. The seed now carries a digest produced by exactly that call (the plaintext still exists nowhere in code, tests or comments). Because the seed is `INSERT … WHERE NOT EXISTS`, pre-existing databases keep their old row — so every `open()` also runs an idempotent rotation (`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`): a no-op once healed, it repairs legacy rows at boot and is durable on the next persist. Unit tests cover the fresh seed with the new digest plus the rotate-on-open path (legacy file heals to the current digest; second open is a no-op).
|
||||
|
||||
## v0.71.0 — 2026-09-24 09:18
|
||||
- **Admin panel.** A new "Admin" button on the right of the top bar opens a reusable modal (`components/Modal.tsx` — fixed overlay at z-index 60, above the z-50 toast stack; centered `role="dialog"` panel; closes on Escape and backdrop click) hosting `components/AdminPanel.tsx`. No new URL routes: everything lives behind that one button + modal.
|
||||
- **Users + sessions tables.** New `users` (id PK, unique username, scrypt `salt$hex` password_hash, admin flag, created_at) and `sessions` (UUID id, user_id, 7-day expires_at) tables in the raw DDL (`lib/db/client.ts`) and typed Drizzle entries (`lib/db/schema.ts`). An **idempotent seeded admin** is inserted on every `open()` — user id `admin-kamilkrzywda`, username `kamilkrzywda`, admin=1, hash-only (the plaintext exists nowhere in code, tests or comments) — guarded by `WHERE NOT EXISTS` so pre-existing production DBs gain exactly one row and never a duplicate. The users table is also the foundation for multiplayer auth (#105/#107).
|
||||
- **Auth API.** `POST /api/login {username,password}` verifies with node:crypto `scryptSync` + `timingSafeEqual`; an unknown username still runs one full scrypt against a fixed dummy digest so timing stays uniform. Success mints a 7-day session (lazy cleanup of expired rows) and returns `{sessionId, user:{username,admin}}`. `POST /api/logout` deletes the session named by the new `X-Flight-Session` header (idempotent); `GET /api/me` resolves it to the current user or 401. The client stores the sessionId in localStorage (`flight.session`) and sends the header on every authenticated request.
|
||||
- **Generic whole-database CRUD behind the admin flag.** `lib/auth.ts`'s `requireAdmin` guard (no/expired session → 401, non-admin → 403) runs before any input is touched on: `GET /api/admin/tables` (user tables, sqlite_% excluded) and `GET|PUT|POST|DELETE /api/admin/tables/{name}/rows` — rows carry their rowid as `__rowid` so the PK-less tables (account, sim_state) are editable like any other; PUT updates only changed cells, POST inserts, DELETE removes by rowid. SQL safety: table names quoted only after sqlite_master validation, column names validated against PRAGMA table_info, values ALWAYS bound as parameters — no user input is ever concatenated into SQL.
|
||||
- **UI.** The Admin modal shows a login form (wrong credentials show an error line), a "not an admin" notice for authenticated non-admins, or the grid: table list → per-table editable rows (each cell an input; per-row Save/Delete, "+ Add row", Refresh) with logout in the header area. Deliberately plain — it's a dev tool surface, not a product feature.
|
||||
- **Test hook.** `POST /api/test/admin-session` (FLIGHT_TEST_HOOKS=1 gate like its siblings) mints a session for the seeded admin server-side and returns `{sessionId}` — so the e2e suite can exercise the authenticated panel WITHOUT the real admin password ever appearing in test code.
|
||||
- **OpenAPI.** Five new paths documented (`/api/login`, `/api/logout`, `/api/me`, `/api/admin/tables`, `/api/admin/tables/{name}/rows` with all four verbs) plus their request/response schemas; endpoint count twelve → seventeen everywhere it is asserted (unit + e2e).
|
||||
- **Tests.** Unit: the idempotent admin seed (fresh DB, reopen, and a legacy pre-v0.71.0 file), login success/wrong-password/unknown-username/malformed bodies with an in-test scrypt hash for a self-created user (the real admin's plaintext is never referenced), logout idempotency, /api/me with missing/unknown/expired/live sessions, the guard ladder (401 no header, 401 expired, 403 non-admin) and the full CRUD validation matrix (rows incl. `__rowid` for account, PUT-verified-by-refetch, POST+DELETE round-trip, unknown table 404, unknown column/bad limit/missing rowid 400). E2E (`e2e/admin.spec.ts`, CI-only): open modal → login form → wrong creds error → hook-minted session via localStorage → table list incl. packages/users → edit a cell + Save verified by an independent re-fetch → add + delete a users row → logout back to the login form.
|
||||
|
||||
## v0.70.0 — 2026-09-23 22:07
|
||||
- **Default map view is now the whole world.** The initial camera in `components/Map.tsx` moves from the Europe overview `[10,52]` z4 to null island `[0,0]` (renamed constant `WORLD_CENTER`) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. `MIN_ZOOM` is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; `MAX_ZOOM = 11` and the flat-2D view are unchanged.
|
||||
- **E2E.** The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: `e2e/map.spec.ts` gains a shared `frameLfrs` helper (`jumpTo` z5 + wait until the marker renders), used by all three of its click tests; `e2e/map-interactions.spec.ts` and `e2e/relocation.spec.ts` frame LFRS at z5 before their popup clicks. The plane-marker test in `e2e/map-interactions.spec.ts` fits EDDF+LEMD into the viewport first — `queryRenderedFeatures` is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. `e2e/hover.spec.ts`: the antimeridian test's `setMinZoom(0)` workaround is deleted — minZoom is natively 0 now, so `fitToAirports` can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in `e2e/map.spec.ts` pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0).
|
||||
|
||||
## v0.69.0 — 2026-09-23 21:54
|
||||
- **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
|
||||
- **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway.
|
||||
- **Tests.** Unit: the `ignorePackage` suite is deleted; terminal-status fixtures drop their redundant `'ignored'` rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to `'expired'` mirroring `expireOpenPackages`; the taken-slot cooldown ladder keeps two stamped slots, both `'loaded'`. E2E: the Ignore UI test is deleted; `sim.spec.ts`'s soft-lock regression drains via the existing `/api/test/expire-package` hook (loading can't drain a pool — the starter's payload fits only the lightest band) and `expiry.spec.ts` cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.
|
||||
|
||||
## v0.68.0 — 2026-09-23 20:32
|
||||
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
|
||||
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`.
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { DELETE, GET, POST, PUT } from './route';
|
||||
|
||||
// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables),
|
||||
// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input).
|
||||
|
||||
let dir: string;
|
||||
let adminSession = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// adminSession is minted in beforeAll, so the header helpers read it at call time.
|
||||
const authHeaders = () => ({ 'X-Flight-Session': adminSession });
|
||||
|
||||
// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly.
|
||||
const ctx = (name: string) => ({ params: Promise.resolve({ name }) });
|
||||
|
||||
const getRows = (name: string, query = '') =>
|
||||
GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name));
|
||||
const putRow = (name: string, body: unknown) =>
|
||||
PUT(
|
||||
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
ctx(name),
|
||||
);
|
||||
const postRow = (name: string, body: unknown) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
ctx(name),
|
||||
);
|
||||
const deleteRow = (name: string, rowid: number | null) =>
|
||||
DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`,
|
||||
{ method: 'DELETE', headers: authHeaders() },
|
||||
),
|
||||
ctx(name),
|
||||
);
|
||||
|
||||
describe('admin rows CRUD', () => {
|
||||
beforeAll(async () => {
|
||||
adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
});
|
||||
|
||||
it('GET returns columns + rows with __rowid for the PK-less sim_state table', async () => {
|
||||
const res = await getRows('sim_state');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { columns: string[]; rows: Array<Record<string, unknown>> };
|
||||
expect(body.columns[0]).toBe('__rowid');
|
||||
for (const col of ['clock_ms', 'time_scale']) expect(body.columns).toContain(col);
|
||||
expect(body.rows).toHaveLength(1); // the single seeded sim_state row
|
||||
expect(typeof body.rows[0].__rowid).toBe('number');
|
||||
expect(typeof body.rows[0].clock_ms).toBe('number');
|
||||
});
|
||||
|
||||
it('PUT updates a cell and the change survives a re-fetch', async () => {
|
||||
const before = await (await getRows('sim_state')).json();
|
||||
const originalClockMs = (before as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms;
|
||||
|
||||
const res = await putRow('sim_state', { rowid: 1, values: { clock_ms: 424_242 } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
|
||||
// Re-fetch through the same route — the persisted value is back.
|
||||
const body = await (await getRows('sim_state')).json();
|
||||
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms).toBe(424_242);
|
||||
|
||||
// Restore the original clock so later tests see a sane world.
|
||||
const restore = await putRow('sim_state', { rowid: 1, values: { clock_ms: originalClockMs } });
|
||||
expect(restore.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT rejects unknown columns and empty values with 400', async () => {
|
||||
const badCol = await putRow('sim_state', { rowid: 1, values: { no_such_column: 1 } });
|
||||
expect(badCol.status).toBe(400);
|
||||
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||
|
||||
const empty = await putRow('sim_state', { rowid: 1, values: {} });
|
||||
expect(empty.status).toBe(400);
|
||||
|
||||
const noRowid = await putRow('sim_state', { values: { clock_ms: 1 } });
|
||||
expect(noRowid.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST inserts a row and DELETE removes it again (users table)', async () => {
|
||||
const created = await postRow('users', {
|
||||
values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() },
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const body = (await created.json()) as { ok: boolean; rowid: number };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(Number.isInteger(body.rowid)).toBe(true);
|
||||
|
||||
// Visible via GET…
|
||||
let list = await (await getRows('users')).json();
|
||||
const rows = (list as { rows: Array<Record<string, unknown>> }).rows;
|
||||
expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true);
|
||||
|
||||
// …and gone after DELETE.
|
||||
const del = await deleteRow('users', body.rowid);
|
||||
expect(del.status).toBe(200);
|
||||
list = await (await getRows('users')).json();
|
||||
expect((list as { rows: Array<Record<string, unknown>> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false);
|
||||
|
||||
// DELETE is idempotent.
|
||||
const again = await deleteRow('users', body.rowid);
|
||||
expect(again.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST rejects unknown columns and empty values with 400', async () => {
|
||||
const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } });
|
||||
expect(badCol.status).toBe(400);
|
||||
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||
|
||||
const empty = await postRow('users', { values: {} });
|
||||
expect(empty.status).toBe(400);
|
||||
});
|
||||
|
||||
it('DELETE requires an integer rowid query parameter', async () => {
|
||||
const missing = await deleteRow('users', null);
|
||||
expect(missing.status).toBe(400);
|
||||
const bad = await DELETE(
|
||||
new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }),
|
||||
ctx('users'),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown table on every verb', async () => {
|
||||
const g = await getRows('no_such_table');
|
||||
expect(g.status).toBe(404);
|
||||
const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } });
|
||||
expect(p.status).toBe(404);
|
||||
const i = await postRow('no_such_table', { values: { x: 1 } });
|
||||
expect(i.status).toBe(404);
|
||||
const d = await deleteRow('no_such_table', 1);
|
||||
expect(d.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects a non-integer limit with 400 and caps at 1000', async () => {
|
||||
const bad = await getRows('users', '?limit=abc');
|
||||
expect(bad.status).toBe(400);
|
||||
const capped = await getRows('users', '?limit=999999');
|
||||
expect(capped.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => {
|
||||
const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users'));
|
||||
expect(noHeader.status).toBe(401);
|
||||
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`,
|
||||
[Date.now()],
|
||||
);
|
||||
const nonAdminSession = await createSession(inst, 'user-rows-nonadmin');
|
||||
const forbidden = await GET(
|
||||
new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }),
|
||||
ctx('users'),
|
||||
);
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import { persist } from '@/lib/db/client';
|
||||
import type { GameDb } from '@/lib/db/client';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any
|
||||
// user input is touched). SQL safety: table names are quoted only after validation against
|
||||
// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE
|
||||
// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string.
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
const MAX_LIMIT = 1000;
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return '"' + name.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
// The app's own tables (sqlite_% internals excluded) — the only names ever accepted.
|
||||
function tableNames(inst: GameDb): Set<string> {
|
||||
const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
||||
return new Set(res[0].values.map((r) => String(r[0])));
|
||||
}
|
||||
|
||||
// Column names of a validated table (name already checked against sqlite_master).
|
||||
function columnsOf(inst: GameDb, name: string): string[] {
|
||||
const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`);
|
||||
return res[0].values.map((r) => String(r[1]));
|
||||
}
|
||||
|
||||
// Parameterized execute — the only way values reach SQL in this file.
|
||||
function run(inst: GameDb, sql: string, values: unknown[]): void {
|
||||
inst.raw.run(sql, values as never[]);
|
||||
}
|
||||
|
||||
async function readBody(req: Request): Promise<Record<string, unknown> | Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!ALLOWED_BODY_FIELDS.has(key)) {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// Per-method top-level body fields (strict, like the /api/test/* hooks).
|
||||
const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']);
|
||||
|
||||
function valuesObject(input: Record<string, unknown>): Record<string, unknown> | Response {
|
||||
const values = input.values;
|
||||
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
|
||||
return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 });
|
||||
}
|
||||
return values as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit;
|
||||
// anything else is a server error.
|
||||
function sqlError(err: unknown): Response {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) {
|
||||
return Response.json({ error: `database constraint: ${msg}` }, { status: 409 });
|
||||
}
|
||||
console.error('admin rows route failed', err);
|
||||
return Response.json({ error: 'database error' }, { status: 500 });
|
||||
}
|
||||
|
||||
// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default
|
||||
// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable
|
||||
// like any other. Unknown table → 404; bad limit → 400.
|
||||
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const { name } = await ctx.params;
|
||||
if (!tableNames(guard.inst).has(name)) {
|
||||
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||
}
|
||||
let limit = DEFAULT_LIMIT;
|
||||
const limitParam = new URL(req.url).searchParams.get('limit');
|
||||
if (limitParam !== null) {
|
||||
limit = Number(limitParam);
|
||||
if (!Number.isInteger(limit) || limit < 1) {
|
||||
return Response.json({ error: 'limit must be a positive integer' }, { status: 400 });
|
||||
}
|
||||
limit = Math.min(limit, MAX_LIMIT);
|
||||
}
|
||||
|
||||
const cols = columnsOf(guard.inst, name);
|
||||
const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`);
|
||||
try {
|
||||
stmt.bind([limit]);
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
|
||||
return Response.json({ columns: ['__rowid', ...cols], rows });
|
||||
} catch (err) {
|
||||
return sqlError(err);
|
||||
} finally {
|
||||
stmt.free();
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the
|
||||
// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty
|
||||
// values → 400.
|
||||
export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const input = await readBody(req);
|
||||
if (input instanceof Response) return input;
|
||||
const rowid = input.rowid;
|
||||
if (!Number.isInteger(rowid)) {
|
||||
return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 });
|
||||
}
|
||||
const values = await valuesObject(input);
|
||||
if (values instanceof Response) return values;
|
||||
|
||||
const { name } = await ctx.params;
|
||||
if (!tableNames(guard.inst).has(name)) {
|
||||
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||
}
|
||||
const cols = columnsOf(guard.inst, name);
|
||||
for (const col of Object.keys(values)) {
|
||||
if (!cols.includes(col)) {
|
||||
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (Object.keys(values).length === 0) {
|
||||
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
||||
}
|
||||
|
||||
const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', ');
|
||||
try {
|
||||
run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [
|
||||
...Object.values(values),
|
||||
rowid,
|
||||
]);
|
||||
await persist(guard.inst);
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
return sqlError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with
|
||||
// exactly the given columns (others take their defaults or NULL). Returns the new rowid.
|
||||
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const input = await readBody(req);
|
||||
if (input instanceof Response) return input;
|
||||
const values = await valuesObject(input);
|
||||
if (values instanceof Response) return values;
|
||||
if (Object.keys(values).length === 0) {
|
||||
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name } = await ctx.params;
|
||||
if (!tableNames(guard.inst).has(name)) {
|
||||
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||
}
|
||||
const cols = columnsOf(guard.inst, name);
|
||||
for (const col of Object.keys(values)) {
|
||||
if (!cols.includes(col)) {
|
||||
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(values);
|
||||
try {
|
||||
run(
|
||||
guard.inst,
|
||||
`INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||
Object.values(values),
|
||||
);
|
||||
// Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets
|
||||
// last_insert_rowid to 0 on this connection.
|
||||
const res = guard.inst.raw.exec('SELECT last_insert_rowid()');
|
||||
await persist(guard.inst);
|
||||
return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) });
|
||||
} catch (err) {
|
||||
return sqlError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404;
|
||||
// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent).
|
||||
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const { name } = await ctx.params;
|
||||
if (!tableNames(guard.inst).has(name)) {
|
||||
return Response.json({ error: 'unknown table' }, { status: 404 });
|
||||
}
|
||||
const rowidParam = new URL(req.url).searchParams.get('rowid');
|
||||
const rowid = Number(rowidParam);
|
||||
if (rowidParam === null || !Number.isInteger(rowid)) {
|
||||
return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]);
|
||||
await persist(guard.inst);
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
return sqlError(err);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { GET } from './route';
|
||||
|
||||
// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403,
|
||||
// admin passes and sees every user table (sqlite_% excluded).
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (sessionId?: string) =>
|
||||
GET(
|
||||
new Request('http://localhost/api/admin/tables', {
|
||||
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
async function makeUser(username: string, admin: number): Promise<string> {
|
||||
const inst = await getGameDb();
|
||||
const id = `user-${username}`;
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`,
|
||||
[id, username, admin, Date.now(), id],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('GET /api/admin/tables', () => {
|
||||
it('returns 401 without a session header', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for an expired session', async () => {
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||
'expired-admin-session',
|
||||
'admin-kamilkrzywda',
|
||||
Date.now() - 1000,
|
||||
]);
|
||||
const res = await get('expired-admin-session');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a valid non-admin session', async () => {
|
||||
const userId = await makeUser('tables-nonadmin', 0);
|
||||
const sessionId = await createSession(await getGameDb(), userId);
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(403);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('lists every user table for an admin, excluding sqlite_% internals', async () => {
|
||||
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name);
|
||||
|
||||
for (const expected of ['packages', 'players', 'planes', 'sessions', 'sim_state', 'users']) {
|
||||
expect(names, `missing ${expected}`).toContain(expected);
|
||||
}
|
||||
expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false);
|
||||
// Name-ordered.
|
||||
expect([...names].sort()).toEqual(names);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name
|
||||
// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker.
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const res = guard.inst.raw.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
);
|
||||
return Response.json(res[0].values.map((r) => ({ name: String(r[0]) })));
|
||||
}
|
||||
@@ -2,15 +2,9 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createSession, hashPassword } from '@/lib/auth';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { getPlayer } from '@/lib/db/localPlayer';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
import { GET } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
|
||||
@@ -18,8 +12,6 @@ beforeAll(async () => {
|
||||
// vitest gives each test file its own module registry (fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
|
||||
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
|
||||
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -28,18 +20,8 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
|
||||
// param — this test exercises exactly that path (requireAuthSse).
|
||||
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
|
||||
|
||||
it('rejects an unauthenticated request with 401', async () => {
|
||||
const res = await GET(new Request('http://localhost/api/events'));
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||
});
|
||||
|
||||
it('streams SSE with the current snapshot as the first event', async () => {
|
||||
const res = await GET(req());
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
expect(res.headers.get('cache-control')).toBe('no-cache');
|
||||
@@ -52,63 +34,8 @@ describe('GET /api/events', () => {
|
||||
expect(text.startsWith('data: ')).toBe(true);
|
||||
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
||||
|
||||
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
|
||||
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
|
||||
const planes = snap.planes as Array<Record<string, unknown>>;
|
||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['ownerId', 'ownerName']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
const pkgs = snap.packages as Array<Record<string, unknown>>;
|
||||
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
|
||||
} finally {
|
||||
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||
}
|
||||
});
|
||||
|
||||
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
|
||||
const inst = await getGameDb();
|
||||
// A second player with a distinct economy + their own session (rows minted like /api/register).
|
||||
const idB = 'test-player-events-b';
|
||||
await inst.db.insert(schema.users).values({
|
||||
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||
});
|
||||
await inst.db.insert(schema.players).values({
|
||||
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
|
||||
});
|
||||
const sidB = await createSession(inst, idB);
|
||||
const localCash = (await getPlayer(inst, 'local')).cash;
|
||||
|
||||
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
|
||||
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
|
||||
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
|
||||
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
|
||||
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
|
||||
|
||||
// Reads the next `data:` event off a stream and parses its snapshot.
|
||||
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||
const { value } = await reader.read();
|
||||
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
try {
|
||||
// Drain both connect frames (each already carries its own account row)…
|
||||
const connectA = await readFrame(readerA);
|
||||
const connectB = await readFrame(readerB);
|
||||
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
|
||||
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
|
||||
|
||||
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
|
||||
await tickNow();
|
||||
const pushA = await readFrame(readerA);
|
||||
const pushB = await readFrame(readerB);
|
||||
|
||||
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
|
||||
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
|
||||
} finally {
|
||||
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
|
||||
await readerB.cancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+5
-18
@@ -1,4 +1,3 @@
|
||||
import { requireAuthSse } from '@/lib/auth';
|
||||
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
|
||||
import { subscribe } from '@/lib/sse';
|
||||
|
||||
@@ -6,22 +5,12 @@ import { subscribe } from '@/lib/sse';
|
||||
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
|
||||
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
|
||||
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
|
||||
// pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
|
||||
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
|
||||
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
|
||||
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
|
||||
// be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
|
||||
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
|
||||
// player, pushing each frame only to its group; connect + pushed frames share one projection
|
||||
// (getStateSnapshot + sseFrame), so they can never drift.
|
||||
// pushed frames, so a connect never costs one full-history frame.
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const auth = await requireAuthSse(req);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
export async function GET() {
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
const cleanup = () => {
|
||||
@@ -33,12 +22,10 @@ export async function GET(req: Request) {
|
||||
start(controller) {
|
||||
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
||||
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
|
||||
// for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
|
||||
// every later push carry THIS session's player projection — subscribe keys on auth.player.id so
|
||||
// the tick broadcast only sends this client frames projected for its own account row.
|
||||
getStateSnapshot(auth.player.id)
|
||||
// for the next push instead of seeing an out-of-order frame.
|
||||
getStateSnapshot()
|
||||
.then((snapshot) => {
|
||||
unsubscribe = subscribe(auth.player.id, (text) => {
|
||||
unsubscribe = subscribe((text) => {
|
||||
try {
|
||||
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
||||
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { randomUUID, scryptSync } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { SESSION_TTL_MS } from '@/lib/auth';
|
||||
import { POST } from './route';
|
||||
|
||||
// v0.71.0 — login route tests. The test user is created HERE with a hash computed in-test via
|
||||
// scryptSync; the seeded admin's plaintext password must never appear in any test code, so it
|
||||
// is deliberately not exercised here (the e2e suite gets an admin session via /api/test/admin-session).
|
||||
|
||||
let dir: string;
|
||||
const TEST_USER = 'unit-test-user';
|
||||
const TEST_PASSWORD = 'right-horse-wrong-street-42';
|
||||
const SALT_HEX = randomUUID().replace(/-/g, '').slice(0, 32); // 16-byte hex salt
|
||||
const HASH = `${SALT_HEX}$${scryptSync(TEST_PASSWORD, SALT_HEX, 32).toString('hex')}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-login-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'login.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => POST(new Request('http://localhost/api/login', { method: 'POST', body: JSON.stringify(body) }));
|
||||
|
||||
async function createTestUser(): Promise<void> {
|
||||
const inst = await getGameDb();
|
||||
// Idempotent — two tests in this file both want the user.
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'user-unit-test', ?, ?, 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = ?)`,
|
||||
[TEST_USER, HASH, Date.now(), TEST_USER],
|
||||
);
|
||||
}
|
||||
|
||||
describe('POST /api/login', () => {
|
||||
it('rejects malformed bodies with 400', async () => {
|
||||
for (const body of [{}, { username: 'x' }, { password: 'y' }, { username: '', password: 'p' }, { bogus: 1 }]) {
|
||||
const res = await post(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 401 for an unknown username (and still runs a scrypt)', async () => {
|
||||
const res = await post({ username: 'no-such-user', password: TEST_PASSWORD });
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/invalid credentials/i);
|
||||
});
|
||||
|
||||
it('returns 401 for a wrong password and mints no session', async () => {
|
||||
await createTestUser();
|
||||
const before = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0];
|
||||
|
||||
const res = await post({ username: TEST_USER, password: 'definitely-wrong' });
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const after = Number((await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]);
|
||||
expect(after).toBe(Number(before));
|
||||
});
|
||||
|
||||
it('mints a ~7-day session on success and returns the user', async () => {
|
||||
await createTestUser();
|
||||
const t0 = Date.now();
|
||||
|
||||
const res = await post({ username: TEST_USER, password: TEST_PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
|
||||
expect(body.user).toEqual({ username: TEST_USER, admin: false });
|
||||
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
const row = (await getGameDb()).raw.exec('SELECT user_id, expires_at FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
|
||||
expect(String(row[0])).toBe('user-unit-test');
|
||||
const ttl = Number(row[1]) - t0;
|
||||
expect(ttl).toBeGreaterThan(SESSION_TTL_MS - 5_000); // ~7 days, minus test drift
|
||||
expect(ttl).toBeLessThan(SESSION_TTL_MS + 5_000);
|
||||
});
|
||||
|
||||
it('login works for the seeded admin row too (hash verified without its plaintext)', async () => {
|
||||
// The seeded admin's hash is a fixed string in client.ts; we can only prove the route
|
||||
// ACCEPTS exactly that stored digest by checking verification against a known pair — which
|
||||
// would require the plaintext. So instead: assert the seeded row is what login looks up, by
|
||||
// confirming an unknown-password attempt for 'kamilkrzywda' yields 401 (row found, hash mismatch).
|
||||
const res = await post({ username: 'kamilkrzywda', password: 'wrong-on-purpose' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession, verifyPassword, DUMMY_HASH_FOR_UNKNOWN_USER } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — POST /api/login
|
||||
// body { username: string, password: string } (both required non-empty; unknown fields 400).
|
||||
// Success → 200 { sessionId, user: { username, admin } }; the session row lives 7 days and is
|
||||
// sent back as X-Flight-Session on later requests. Failure → 401 { error }. An unknown
|
||||
// username still runs one full scrypt against a fixed dummy digest so timing stays uniform.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'username' && key !== 'password') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const username = input.username;
|
||||
const password = input.password;
|
||||
if (typeof username !== 'string' || username.length === 0) {
|
||||
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
return Response.json({ error: 'password is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const inst = await getGameDb();
|
||||
const res = inst.raw.exec('SELECT id, password_hash, admin FROM users WHERE username = ?', [username]);
|
||||
const row = res[0]?.values?.[0];
|
||||
if (!row) {
|
||||
verifyPassword(password, DUMMY_HASH_FOR_UNKNOWN_USER); // uniform timing — result discarded
|
||||
return Response.json({ error: 'invalid credentials' }, { status: 401 });
|
||||
}
|
||||
if (!verifyPassword(password, String(row[1]))) {
|
||||
return Response.json({ error: 'invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionId = await createSession(inst, String(row[0]));
|
||||
return Response.json({
|
||||
sessionId,
|
||||
user: { username, admin: Number(row[2]) === 1 },
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { POST } from './route';
|
||||
|
||||
// v0.71.0 — logout route tests: deletes the session named by X-Flight-Session, idempotently.
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-logout-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'logout.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (sessionId?: string) =>
|
||||
POST(
|
||||
new Request('http://localhost/api/logout', {
|
||||
method: 'POST',
|
||||
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
async function mintSession(): Promise<string> {
|
||||
const inst = await getGameDb();
|
||||
return createSession(inst, 'admin-kamilkrzywda'); // any user id works — logout only deletes the session row
|
||||
}
|
||||
|
||||
describe('POST /api/logout', () => {
|
||||
it('returns 401 without a session header', async () => {
|
||||
const res = await post();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('deletes the named session and is idempotent on repeat calls', async () => {
|
||||
const sessionId = await mintSession();
|
||||
const count = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
|
||||
expect(Number(count)).toBe(1);
|
||||
|
||||
const first = await post(sessionId);
|
||||
expect(first.status).toBe(200);
|
||||
expect(await first.json()).toEqual({ ok: true });
|
||||
|
||||
// The row is gone…
|
||||
const after = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
|
||||
expect(Number(after)).toBe(0);
|
||||
|
||||
// …and a second logout of the same (now unknown) session is still 200.
|
||||
const second = await post(sessionId);
|
||||
expect(second.status).toBe(200);
|
||||
expect(await second.json()).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { SESSION_HEADER } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — POST /api/logout (no body). Deletes the session named by the X-Flight-Session
|
||||
// header; idempotent — deleting an unknown or already-deleted session is still 200. A missing
|
||||
// header has nothing to delete and gets 401 like every other unauthenticated request here.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const sessionId = req.headers.get(SESSION_HEADER);
|
||||
if (!sessionId) return Response.json({ error: 'authentication required' }, { status: 401 });
|
||||
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
|
||||
await persist(inst);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -2,22 +2,18 @@ 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 { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
|
||||
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 () => {
|
||||
@@ -25,14 +21,7 @@ afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (body: unknown) =>
|
||||
POST(
|
||||
new Request('http://localhost/api/market/buy-plane', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Flight-Session': sessionId },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
|
||||
|
||||
describe('POST /api/market/buy-plane', () => {
|
||||
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
||||
@@ -49,13 +38,14 @@ describe('POST /api/market/buy-plane', () => {
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
||||
|
||||
// Cash untouched by the failed attempt.
|
||||
expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000);
|
||||
const account = (await (await getGameDb()).db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBe(20_000);
|
||||
});
|
||||
|
||||
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
|
||||
const inst = await getGameDb();
|
||||
// Local player row — top up cash so the purchase can succeed.
|
||||
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||
const { db } = await getGameDb();
|
||||
// Single-row account table — top up cash so the purchase can succeed.
|
||||
await db.update(schema.account).set({ cash: 500_000 });
|
||||
|
||||
const res = await post({ model: 'atr72' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const model = input.model;
|
||||
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
|
||||
return performAction((inst) => buyPlane(inst, model));
|
||||
}
|
||||
|
||||
@@ -4,22 +4,17 @@ import path from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
|
||||
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 () => {
|
||||
@@ -27,14 +22,7 @@ afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (body: unknown) =>
|
||||
POST(
|
||||
new Request('http://localhost/api/market/sell-plane', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Flight-Session': sessionId },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
|
||||
|
||||
describe('POST /api/market/sell-plane', () => {
|
||||
it('rejects a missing planeId and unknown fields with 400', async () => {
|
||||
@@ -60,7 +48,7 @@ describe('POST /api/market/sell-plane', () => {
|
||||
const { db } = await getGameDb();
|
||||
// A second, grounded plane so "flying" is the reason for rejection.
|
||||
await db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
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'));
|
||||
@@ -71,10 +59,10 @@ describe('POST /api/market/sell-plane', () => {
|
||||
});
|
||||
|
||||
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
|
||||
const inst = await getGameDb();
|
||||
const { db } = await getGameDb();
|
||||
// The previous test left 'plane-extra' in the DB (now flying) — ground it.
|
||||
await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post({ planeId: 'plane-extra' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const planeId = input.planeId;
|
||||
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
|
||||
return performAction((inst) => sellPlane(inst, planeId));
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { GET } from './route';
|
||||
|
||||
// v0.71.0 — /api/me tests: resolves X-Flight-Session to its user, 401 otherwise.
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-me-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'me.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (sessionId?: string) =>
|
||||
GET(
|
||||
new Request('http://localhost/api/me', {
|
||||
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('GET /api/me', () => {
|
||||
it('returns 401 without a session header', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for an unknown session id', async () => {
|
||||
const res = await get('00000000-0000-4000-8000-000000000000');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for an expired session', async () => {
|
||||
const inst = await getGameDb();
|
||||
// A session whose expiry is already in the past.
|
||||
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||
'expired-session-id',
|
||||
'admin-kamilkrzywda',
|
||||
Date.now() - 1000,
|
||||
]);
|
||||
const res = await get('expired-session-id');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the user for a live session (admin flag included)', async () => {
|
||||
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ username: 'kamilkrzywda', admin: true });
|
||||
});
|
||||
|
||||
it('reports non-admin users as such', async () => {
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'user-me-test', 'me-test-user', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-me-test')`,
|
||||
[Date.now()],
|
||||
);
|
||||
const sessionId = await createSession(inst, 'user-me-test');
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ username: 'me-test-user', admin: false });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { currentUser } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — GET /api/me (no body). Returns the user behind a valid X-Flight-Session header as
|
||||
// { username, admin }, or 401 when the header is missing/unknown/expired. The client uses it to
|
||||
// decide what the Admin modal shows on open.
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const user = await currentUser(req);
|
||||
if (!user) return Response.json({ error: 'authentication required' }, { status: 401 });
|
||||
return Response.json({ username: user.username, admin: user.admin });
|
||||
}
|
||||
@@ -4,21 +4,16 @@ import path from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
||||
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 () => {
|
||||
@@ -26,11 +21,9 @@ afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (id: string) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -91,9 +84,8 @@ describe('POST /api/packages/:id/abandon', () => {
|
||||
|
||||
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
|
||||
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
|
||||
const inst = await getGameDb();
|
||||
const { db } = inst;
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const { db } = await getGameDb();
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post('p-ab');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
|
||||
return performAction((inst) => abandonPackage(inst, id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ignorePackage, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
|
||||
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
|
||||
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
return performAction((inst) => ignorePackage(inst, id));
|
||||
}
|
||||
@@ -5,19 +5,15 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
||||
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 () => {
|
||||
@@ -26,14 +22,9 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
const post = (id: string, body: unknown) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/packages/${id}/load`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Flight-Session': sessionId },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
|
||||
describe('POST /api/packages/:id/load', () => {
|
||||
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
||||
|
||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const planeId = input.planeId;
|
||||
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
|
||||
return performAction((inst) => loadPackage(inst, id, planeId));
|
||||
}
|
||||
|
||||
@@ -5,19 +5,15 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
||||
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 () => {
|
||||
@@ -26,14 +22,9 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
const post = (planeId: string, body: unknown) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/planes/${planeId}/fly`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Flight-Session': sessionId },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ planeId }) },
|
||||
);
|
||||
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
|
||||
params: Promise.resolve({ planeId }),
|
||||
});
|
||||
|
||||
describe('POST /api/planes/:planeId/fly', () => {
|
||||
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
||||
|
||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
||||
}
|
||||
|
||||
const destIcao = input.destIcao;
|
||||
return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
|
||||
return performAction((inst) => flyPlane(inst, planeId, destIcao));
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
||||
}
|
||||
|
||||
const index = input.index as number;
|
||||
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
|
||||
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
|
||||
}
|
||||
|
||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
||||
}
|
||||
|
||||
const destIcao = input.destIcao;
|
||||
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
|
||||
return performAction((inst) => queueLeg(inst, planeId, destIcao));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// 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;
|
||||
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
|
||||
return performAction((inst) => repairPlane(inst, planeId));
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { 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);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
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,17 +2,12 @@ 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 { eq } from 'drizzle-orm';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||
import { createSession, hashPassword } from '@/lib/auth';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { GET } from './route';
|
||||
|
||||
let dir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Point the app's DB singleton at a throwaway file so this test never
|
||||
@@ -20,14 +15,8 @@ beforeAll(async () => {
|
||||
// vitest gives each test file its own module registry (fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
|
||||
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 () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
@@ -35,7 +24,7 @@ afterAll(async () => {
|
||||
|
||||
describe('GET /api/state', () => {
|
||||
it('returns 200 with the full snapshot shape', async () => {
|
||||
const res = await GET(req());
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
@@ -59,8 +48,8 @@ describe('GET /api/state', () => {
|
||||
|
||||
const planes = body.planes as Array<Record<string, unknown>>;
|
||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
expect(typeof planes[0].locationName).toBe('string');
|
||||
// The seeded starter is grounded — no flight destination yet.
|
||||
@@ -69,8 +58,8 @@ describe('GET /api/state', () => {
|
||||
|
||||
const packages = body.packages as Array<Record<string, unknown>>;
|
||||
expect(packages.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
||||
expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||
@@ -86,13 +75,13 @@ describe('GET /api/state', () => {
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||
const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||
const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
||||
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
||||
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
|
||||
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 () => {
|
||||
const body = ((await GET(req())).json()) as Promise<{
|
||||
const body = ((await GET()).json()) as Promise<{
|
||||
catalog: Array<Record<string, unknown>>;
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
@@ -115,71 +104,4 @@ describe('GET /api/state', () => {
|
||||
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
||||
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,19 +1,12 @@
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
|
||||
// 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;
|
||||
export async function GET() {
|
||||
try {
|
||||
// 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).
|
||||
await tickNow();
|
||||
return Response.json(await getStateSnapshot(auth.player.id));
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('failed to load game state', err);
|
||||
return Response.json({ error: 'failed to load game state' }, { status: 500 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
@@ -46,12 +46,11 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
|
||||
// player the e2e suite plays via mint-player-session's default).
|
||||
const player = await getLocalPlayer(inst);
|
||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
|
||||
// TEST-ONLY hook (v0.71.0) — mints a live session for the SEEDED admin user server-side and
|
||||
// returns its id, so the e2e suite can exercise the authenticated Admin panel WITHOUT the real
|
||||
// admin password ever appearing in test code (the spec forbids it). No body. Disabled unless
|
||||
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.ci.yml for the CI stack only); every other
|
||||
// deployment 404s here, like its /api/test/* siblings.
|
||||
|
||||
export async function POST() {
|
||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
const inst = await getGameDb();
|
||||
const res = inst.raw.exec("SELECT id FROM users WHERE username = 'kamilkrzywda'");
|
||||
const userId = res[0]?.values?.[0]?.[0];
|
||||
if (typeof userId !== 'string') {
|
||||
return Response.json({ error: 'seeded admin user missing' }, { status: 500 });
|
||||
}
|
||||
const sessionId = await createSession(inst, userId);
|
||||
return Response.json({ sessionId });
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||
@@ -85,7 +84,8 @@ export async function POST(req: Request) {
|
||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
|
||||
// as engine spawns; never force a fixed value here.
|
||||
const player = await getLocalPlayer(inst);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
const created = generatePackage({
|
||||
id: randomUUID(),
|
||||
airports,
|
||||
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
|
||||
weightKg: input.weightKg as number | undefined,
|
||||
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||||
special: input.special === undefined ? null : (input.special as string | null),
|
||||
reputation: player.reputation,
|
||||
reputation: account.reputation,
|
||||
});
|
||||
await inst.db.insert(schema.packages).values(created);
|
||||
await persist(inst);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
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 { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
@@ -42,12 +42,11 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
|
||||
// the e2e suite plays via mint-player-session's default).
|
||||
await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
|
||||
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
|
||||
await persist(inst);
|
||||
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -58,8 +57,7 @@ export async function POST(req: Request) {
|
||||
|
||||
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -75,8 +74,7 @@ export async function POST(req: Request) {
|
||||
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
||||
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
|
||||
-219
@@ -244,15 +244,6 @@ body {
|
||||
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 {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
@@ -597,213 +588,3 @@ body {
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* v0.71.0 — Admin modal: overlay sits above the z-50 toast stack; panel matches the dark slate theme. */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60; /* above toasts (z-50) and everything else on screen */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 23, 0.7);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, 0.97);
|
||||
color: #e2e8f0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 40px rgba(2, 6, 23, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* v0.71.0 — Admin panel content (login form + generic DB grid). */
|
||||
.admin-panel {
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-login {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-panel label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.admin-panel input[type='text'],
|
||||
.admin-panel input:not([type]),
|
||||
.admin-login input {
|
||||
font-size: 13px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
font-size: 12px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-rows-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.admin-rows {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-rows th,
|
||||
.admin-rows td {
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.admin-rows th {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-cell {
|
||||
width: 110px;
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-cell:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.admin-row-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* v1.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;
|
||||
}
|
||||
|
||||
+1
-47
@@ -4,10 +4,8 @@ import dynamic from 'next/dynamic';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import LeftPane from '@/components/LeftPane';
|
||||
import RightPane from '@/components/RightPane';
|
||||
import SignInScreen from '@/components/SignInScreen';
|
||||
import ToastStack from '@/components/Toasts';
|
||||
import type { HoverTarget } from '@/components/hoverTypes';
|
||||
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
|
||||
import { useGameState } from '@/lib/useGameState';
|
||||
import { useToasts } from '@/lib/useToasts';
|
||||
import { diffSnapshots } from '@/lib/deriveEvents';
|
||||
@@ -15,50 +13,6 @@ import { diffSnapshots } from '@/lib/deriveEvents';
|
||||
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = storedSession();
|
||||
if (!sid) {
|
||||
setAuthed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
||||
setAuthed(res.ok);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (authed === null) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<p data-testid="auth-checking" className="sign-in-loading">
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!authed) {
|
||||
return <SignInScreen onAuthed={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||
return <AppShell onLogout={() => setAuthed(false)} />;
|
||||
}
|
||||
|
||||
function AppShell({ onLogout }: { onLogout: () => void }) {
|
||||
// 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)
|
||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||
@@ -95,7 +49,7 @@ function AppShell({ onLogout }: { onLogout: () => void }) {
|
||||
{/* 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
|
||||
action response into the shared state, exactly like RightPane does */}
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||
</div>
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
|
||||
|
||||
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
|
||||
// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD
|
||||
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
|
||||
|
||||
interface Me {
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
interface RowsData {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
|
||||
const [session, setSession] = useState<string | null>(null);
|
||||
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// Login form state.
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
// Admin grid state.
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [table, setTable] = useState<string | null>(null);
|
||||
const [rowsData, setRowsData] = useState<RowsData | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
|
||||
const [newRow, setNewRow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
|
||||
|
||||
// On mount: a stored session decides between the grid and the login form.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const stored = storedSession();
|
||||
if (!stored) {
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
fetch('/api/me', { headers: authHeaders(stored) })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || cancelled) {
|
||||
clearStoredSession();
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as Me;
|
||||
setSession(stored);
|
||||
setMe(body);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
clearStoredSession();
|
||||
setSession(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authHeaders]);
|
||||
|
||||
const loadTables = useCallback(async (s: string) => {
|
||||
const res = await fetch('/api/admin/tables', { headers: authHeaders(s) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name));
|
||||
}, [authHeaders]);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (s: string, name: string) => {
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
setRowsData(await res.json());
|
||||
},
|
||||
[authHeaders],
|
||||
);
|
||||
|
||||
// Enter the grid once /api/me confirms an admin.
|
||||
useEffect(() => {
|
||||
if (!session || !me?.admin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await loadTables(session);
|
||||
if (!cancelled) setBusy(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session, me, loadTables]);
|
||||
|
||||
const openTable = async (name: string) => {
|
||||
if (!session) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loadRows(session, name);
|
||||
setTable(name);
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (!session || !table) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loadRows(session, table);
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
if (!username || !password) return;
|
||||
setLoginError(null);
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as { sessionId: string; user: Me };
|
||||
storeSession(body.sessionId);
|
||||
setSession(body.sessionId);
|
||||
setMe(body.user);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
|
||||
clearStoredSession();
|
||||
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
|
||||
setSession(null);
|
||||
setMe(null);
|
||||
setTable(null);
|
||||
setRowsData(null);
|
||||
setTables([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (checking) return <div className="admin-panel">loading…</div>;
|
||||
|
||||
// No valid session → login form.
|
||||
if (!session || !me) {
|
||||
return (
|
||||
<form
|
||||
className="admin-panel admin-login"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void login();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
data-testid="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{loginError && (
|
||||
<div data-testid="admin-error" className="admin-error">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticated but not an admin.
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<p data-testid="admin-not-admin" className="admin-message">
|
||||
Logged in as <strong>{me.username}</strong> — this account is not an admin.
|
||||
</p>
|
||||
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = rowsData?.columns ?? [];
|
||||
// Displayed grid: server rows plus one trailing empty row when "+ Add row" is active.
|
||||
const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0);
|
||||
const cellValue = (rowIdx: number, col: string): string => {
|
||||
if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? '');
|
||||
const key = `${rowIdx}:${col}`;
|
||||
if (key in edits) return edits[key];
|
||||
const raw = rowsData?.rows[rowIdx]?.[col];
|
||||
return raw === null || raw === undefined ? '' : String(raw);
|
||||
};
|
||||
|
||||
const saveRow = async (rowIdx: number) => {
|
||||
if (!session || !table || !rowsData) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (newRow && rowIdx === rowsData.rows.length) {
|
||||
// New row → POST the non-empty cells.
|
||||
const values: Record<string, string> = {};
|
||||
for (const col of columns) {
|
||||
if (col === '__rowid') continue;
|
||||
const v = cellValue(rowIdx, col);
|
||||
if (v !== '') values[col] = v;
|
||||
}
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
} else {
|
||||
// Existing row → PUT only the changed cells (cleared cell = NULL).
|
||||
const values: Record<string, string | null> = {};
|
||||
for (const col of columns) {
|
||||
if (col === '__rowid') continue;
|
||||
const original = rowsData.rows[rowIdx]?.[col];
|
||||
const origStr = original === null || original === undefined ? '' : String(original);
|
||||
const v = cellValue(rowIdx, col);
|
||||
if (v !== origStr) values[col] = v === '' ? null : v;
|
||||
}
|
||||
if (Object.keys(values).length === 0) return; // nothing changed
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||
body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadRows(session, table); // re-fetch so the grid shows server state (persistence check)
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (rowIdx: number) => {
|
||||
if (!session || !table || !rowsData?.rows[rowIdx]) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rowid = rowsData.rows[rowIdx]?.__rowid;
|
||||
const res = await fetch(
|
||||
`/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`,
|
||||
{ method: 'DELETE', headers: authHeaders(session) },
|
||||
);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
await loadRows(session, table);
|
||||
setEdits({});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<div className="admin-toolbar">
|
||||
<span data-testid="admin-user" className="admin-message">
|
||||
{me.username} (admin)
|
||||
</span>
|
||||
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!table && (
|
||||
<>
|
||||
<p className="admin-message">Pick a table:</p>
|
||||
<div data-testid="admin-tables" className="admin-table-list">
|
||||
{tables.map((t) => (
|
||||
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{table && rowsData && (
|
||||
<>
|
||||
<div className="admin-toolbar">
|
||||
<strong data-testid="admin-current-table">{table}</strong>
|
||||
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
|
||||
← tables
|
||||
</button>
|
||||
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="admin-add-row"
|
||||
className="pane-btn"
|
||||
disabled={busy || newRow}
|
||||
onClick={() => setNewRow(true)}
|
||||
>
|
||||
+ Add row
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-rows-wrap">
|
||||
<table data-testid="admin-rows" className="admin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c}>{c}</th>
|
||||
))}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }, (_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c}>
|
||||
<input
|
||||
data-testid={`admin-cell-${i}-${c}`}
|
||||
className="admin-cell"
|
||||
value={cellValue(i, c)}
|
||||
disabled={busy || c === '__rowid'}
|
||||
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="admin-row-actions">
|
||||
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
|
||||
Save
|
||||
</button>{' '}
|
||||
{!(newRow && i === rowsData.rows.length) && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`admin-delete-${i}`}
|
||||
className="pane-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void deleteRow(i)}
|
||||
>
|
||||
Delete
|
||||
</button>{' '}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div data-testid="admin-error" className="admin-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,10 +157,6 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
|
||||
>
|
||||
<div className="plane-main">
|
||||
<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' ? (
|
||||
<>
|
||||
<span className="plane-status status-flying">in flight</span>
|
||||
|
||||
+2
-19
@@ -11,9 +11,6 @@ import type { HoverTarget } from './hoverTypes';
|
||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { approxLocalTime } from '../lib/localTime';
|
||||
import Modal from './Modal';
|
||||
import AdminPanel from './AdminPanel';
|
||||
import { 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
|
||||
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
||||
@@ -70,14 +67,11 @@ interface MapProps {
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
|
||||
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
// /api/airports is fetched at most once per page life: every styledata event
|
||||
@@ -95,9 +89,6 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
||||
// stored session on every open.
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||
@@ -299,8 +290,7 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||
method: 'POST',
|
||||
// v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
|
||||
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destIcao: p.icao }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
@@ -589,15 +579,8 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
<span data-testid="airport-count">
|
||||
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
||||
</span>
|
||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||
Admin
|
||||
</button>
|
||||
</header>
|
||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||
<AdminPanel onLogout={onLogout} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack),
|
||||
// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it).
|
||||
// Foundation for future modal-based features; keep this generic.
|
||||
export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,12 +209,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
{formatSpecialLabel(pkg.special)}
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
|
||||
{pkg.carrierName && (
|
||||
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
|
||||
{pkg.carrierName}
|
||||
</span>
|
||||
)}
|
||||
{eligible.length > 0 ? (
|
||||
<LoadControls
|
||||
pkg={pkg}
|
||||
@@ -227,6 +221,16 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`ignore-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,6 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||
ownerName: 'kamilkrzywda',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -50,7 +48,6 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
||||
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import { initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { windBurnFactor } from '../lib/sim/movement';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
|
||||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
|
||||
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
||||
// 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
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.71.0 — Admin panel e2e: top-bar button → modal; wrong credentials show an error; a session
|
||||
// minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates WITHOUT the real
|
||||
// admin password ever appearing in test code; then the generic DB grid is exercised end-to-end:
|
||||
// table list → edit + save a cell (verified by an independent re-fetch) → add a row → delete it →
|
||||
// logout. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated
|
||||
// visitors see IT instead of the shell, and logout drops back to it.
|
||||
|
||||
const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||
|
||||
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
|
||||
const res = await page.request.post('/api/test/admin-session');
|
||||
expect(res.status(), 'admin-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||
}
|
||||
|
||||
test('admin panel: sign-in gate, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107).
|
||||
const signIn = page.getByTestId('sign-in-form');
|
||||
await expect(signIn).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('app-header')).toHaveCount(0);
|
||||
|
||||
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
|
||||
await page.getByTestId('sign-in-username').fill('kamilkrzywda');
|
||||
await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose');
|
||||
await page.getByTestId('sign-in-submit').click();
|
||||
await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// --- Authenticate via the test hook + localStorage, then reload into the shell.
|
||||
const sessionId = await mintAdminSession(page);
|
||||
// Both values must be passed as arguments — the function runs in the browser, where Node-side
|
||||
// variables are not in scope.
|
||||
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
|
||||
// The independent re-fetch below goes through page.request → needs the header at context level.
|
||||
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
|
||||
await page.reload();
|
||||
const adminBtn = page.getByTestId('admin-btn');
|
||||
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// --- Open the modal: stored session is valid → straight to the table list.
|
||||
await adminBtn.click();
|
||||
|
||||
// --- Authenticated admin → table list including packages and users.
|
||||
const tables = page.getByTestId('admin-tables');
|
||||
await expect(tables).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('admin-table-packages')).toBeVisible();
|
||||
await expect(page.getByTestId('admin-table-users')).toBeVisible();
|
||||
|
||||
// --- Open packages, edit one cell, Save; verify via an INDEPENDENT re-fetch of the same route.
|
||||
await page.getByTestId('admin-table-packages').click();
|
||||
const grid = page.getByTestId('admin-rows');
|
||||
await expect(grid).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const rowidCell = page.getByTestId('admin-cell-0-__rowid');
|
||||
const originalRowid = Number(await rowidCell.inputValue());
|
||||
expect(Number.isInteger(originalRowid)).toBeTruthy();
|
||||
|
||||
const rewardCell = page.getByTestId('admin-cell-0-reward');
|
||||
await rewardCell.fill('987654');
|
||||
await page.getByTestId('admin-save-0').click();
|
||||
|
||||
// The grid re-fetches after save — the edited cell shows the new value…
|
||||
await expect(rewardCell).toHaveValue('987654', { timeout: 15_000 });
|
||||
// …and an independent request (not the grid's) confirms it persisted server-side.
|
||||
const check = await page.request.get('/api/admin/tables/packages/rows?limit=500', {
|
||||
headers: { 'X-Flight-Session': sessionId },
|
||||
});
|
||||
expect(check.status()).toBe(200);
|
||||
const fetched = (await check.json()) as { rows: Array<Record<string, unknown>> };
|
||||
const row = fetched.rows.find((r) => Number(r.__rowid) === originalRowid);
|
||||
expect(row, 'edited packages row must still exist').toBeTruthy();
|
||||
expect(Number(row?.reward)).toBe(987654);
|
||||
|
||||
// --- Add a row (users table — fewest required columns), then delete it again.
|
||||
await page.getByTestId('admin-back').click();
|
||||
await expect(tables).toBeVisible();
|
||||
await page.getByTestId('admin-table-users').click();
|
||||
const usersGrid = page.getByTestId('admin-rows');
|
||||
await expect(usersGrid).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const existingRows = await page.getByTestId(/admin-save-\d+/).count();
|
||||
await page.getByTestId('admin-add-row').click();
|
||||
const n = existingRows; // the new empty row is appended last
|
||||
await page.getByTestId(`admin-cell-${n}-id`).fill('user-e2e-admin-test');
|
||||
await page.getByTestId(`admin-cell-${n}-username`).fill('e2e-admin-user');
|
||||
await page.getByTestId(`admin-cell-${n}-password_hash`).fill('aa$bb'); // any string — dev-tool insert, not a real credential
|
||||
await page.getByTestId(`admin-cell-${n}-created_at`).fill(String(Date.now()));
|
||||
await page.getByTestId(`admin-save-${n}`).click();
|
||||
|
||||
// The inserted row is visible after the grid's post-save re-fetch.
|
||||
const bodyRows = usersGrid.locator('tbody tr');
|
||||
const findUserRow = async (): Promise<number> => {
|
||||
const count = await bodyRows.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if ((await page.getByTestId(`admin-cell-${i}-username`).inputValue()) === 'e2e-admin-user') return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
await expect.poll(findUserRow, { timeout: 15_000 }).toBeGreaterThanOrEqual(0);
|
||||
const addedIdx = await findUserRow();
|
||||
|
||||
// Delete it again — gone after the post-delete re-fetch.
|
||||
await page.getByTestId(`admin-delete-${addedIdx}`).click();
|
||||
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
|
||||
|
||||
// --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell).
|
||||
await page.getByTestId('admin-logout').click();
|
||||
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(tables).toBeHidden();
|
||||
});
|
||||
+3
-9
@@ -13,32 +13,26 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all eighteen endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
||||
expect(spec.info.title).toBe('Flight Ops API');
|
||||
expect(spec.info.version).toBe(pkg.version);
|
||||
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly;
|
||||
// v0.71.0 adds the admin panel (login/logout/me + guarded DB CRUD).
|
||||
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
|
||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||
'/api/admin/tables', // v0.71.0 — admin panel
|
||||
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/login', // v0.71.0 — admin panel auth
|
||||
'/api/logout', // v0.71.0
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/me', // v0.71.0
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue',
|
||||
'/api/planes/{planeId}/queue/cancel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
]);
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
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
|
||||
|
||||
export async function mintPlayerSession(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session');
|
||||
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;
|
||||
}
|
||||
+3
-9
@@ -12,17 +12,11 @@ interface StateBody {
|
||||
}
|
||||
|
||||
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history
|
||||
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
|
||||
// (delivered/abandoned/ignored) is unbounded in the DB and must never leak into a pushed frame.
|
||||
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
|
||||
|
||||
test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
|
||||
// v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client
|
||||
// passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path.
|
||||
const mint = await request.post('/api/test/mint-player-session');
|
||||
expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||
const { sessionId } = (await mint.json()) as { sessionId: string };
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } });
|
||||
test('GET /api/events streams state snapshots over SSE', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
expect(res.body).not.toBeNull();
|
||||
|
||||
+3
-3
@@ -63,10 +63,10 @@ test('long-leg priority packages always expire after travel time at max speed +
|
||||
created.push(pkg);
|
||||
}
|
||||
} finally {
|
||||
// Cleanup: age out every package this test created so it leaves no trace on the shared board.
|
||||
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
|
||||
for (const pkg of created) {
|
||||
const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
|
||||
expect(r.ok(), `expiring ${pkg.id}`).toBeTruthy();
|
||||
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||||
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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.
|
||||
// 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
|
||||
|
||||
@@ -3,11 +3,6 @@ import { expect, test } from '@playwright/test';
|
||||
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
||||
import { REPAIR_COST } from '../lib/game/catalog';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
|
||||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
|
||||
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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"):
|
||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||
// deterministic generated weather), plane markers whose positions come from the
|
||||
|
||||
+4
-15
@@ -2,11 +2,6 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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 = '/';
|
||||
|
||||
// Read at test time so the assertion tracks package.json automatically.
|
||||
@@ -173,18 +168,12 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
|
||||
await waitForStableZoom();
|
||||
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
|
||||
|
||||
// Zoom out to the effective minimum. MapLibre won't zoom out past the point where
|
||||
// the world no longer covers the viewport height (fit floor = log2(h/512)), so with
|
||||
// MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see
|
||||
// the whole Earth", which is exactly what min zoom should give.
|
||||
const mapBox = await page.getByTestId('map').boundingBox();
|
||||
if (!mapBox) throw new Error('map bounding box unavailable');
|
||||
const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512));
|
||||
await jumpTo(minLimit + 1);
|
||||
await wheelToLimit(600, (z) => z <= minLimit + 1e-9);
|
||||
// Zoom out well below MIN_ZOOM with several large deltas.
|
||||
await jumpTo(MIN_ZOOM + 1);
|
||||
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9);
|
||||
await waitForStableZoom();
|
||||
const zoom = await getZoom();
|
||||
expect(zoom).toBeCloseTo(minLimit);
|
||||
expect(zoom).toBeCloseTo(MIN_ZOOM);
|
||||
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
|
||||
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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.
|
||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
|
||||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
|
||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||
// while flying, ISS-053), and
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Page } 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.
|
||||
// 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)
|
||||
|
||||
+33
-6
@@ -9,11 +9,6 @@ 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 { 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
|
||||
// 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 +
|
||||
@@ -45,7 +40,7 @@ interface PackageState {
|
||||
originName: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
deadlineSimTime: number;
|
||||
@@ -452,6 +447,38 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||
|
||||
await page.goto('/');
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
|
||||
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
|
||||
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
|
||||
await ignoreBtn.click();
|
||||
|
||||
// Server truth: the package is 'ignored'…
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
|
||||
timeout: 15_000,
|
||||
message: 'waiting for the ignored status in /api/state',
|
||||
})
|
||||
.toBe('ignored');
|
||||
|
||||
// …and the row is gone from the open list (the section filters status==='open').
|
||||
await expect(row).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
|
||||
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||||
expect(again.status()).toBe(409);
|
||||
});
|
||||
|
||||
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
|
||||
|
||||
+10
-16
@@ -4,11 +4,6 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
// pool, at EDDF.
|
||||
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
|
||||
// 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
|
||||
@@ -89,7 +84,7 @@ test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) =
|
||||
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
|
||||
});
|
||||
|
||||
test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
|
||||
test('a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
|
||||
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
|
||||
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
|
||||
await expect
|
||||
@@ -105,24 +100,23 @@ test('a grounded fleet never starves: draining every open package cannot empty t
|
||||
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||
const clockBefore = before.simState.clockMs;
|
||||
|
||||
// Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
|
||||
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
|
||||
// only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
|
||||
// reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
|
||||
// its removal, so the market can never empty; cap the passes to bound runtime either way (a
|
||||
// zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
|
||||
// board comes back on the very next tick.
|
||||
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never
|
||||
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every
|
||||
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to
|
||||
// bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring
|
||||
// starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board
|
||||
// comes back a few seconds after the drain, not instantly.
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const s = await getState(page);
|
||||
const open = s.packages.filter((p) => p.status === 'open');
|
||||
if (open.length === 0) break; // drained — the pre-fix soft-lock state
|
||||
for (const p of open) {
|
||||
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/packages/${p.id}/ignore`)).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
|
||||
// pool right away — within ~15 s of real time, with headroom for dind slowness…
|
||||
// The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real
|
||||
// time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)…
|
||||
let topped: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -2,11 +2,6 @@ import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Page } 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
|
||||
// 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
|
||||
|
||||
@@ -6,11 +6,6 @@ import { initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { windBurnFactor } from '../lib/sim/movement';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
|
||||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
|
||||
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||
@@ -43,7 +38,7 @@ interface PackageState {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
destIcao: string;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||
planeId: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession, hashPassword, requireAuth, verifyPassword } from './auth';
|
||||
|
||||
// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as
|
||||
// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file.
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const reqWith = (sessionId?: string) =>
|
||||
new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} });
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('round-trips a password through the stored digest', () => {
|
||||
const hash = hashPassword('correct-horse-battery-staple');
|
||||
expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's
|
||||
expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong password and malformed digests', () => {
|
||||
const hash = hashPassword('some-password');
|
||||
expect(verifyPassword('other-password', hash)).toBe(false);
|
||||
expect(verifyPassword('x', 'not-a-digest')).toBe(false);
|
||||
expect(verifyPassword('x', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('401s when the header is missing or the session id is unknown', async () => {
|
||||
for (const req of [reqWith(), reqWith('no-such-session')]) {
|
||||
const guard = await requireAuth(req);
|
||||
expect(guard.ok).toBe(false);
|
||||
if (!guard.ok) {
|
||||
expect(guard.response.status).toBe(401);
|
||||
expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('401s an expired session', async () => {
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]);
|
||||
const guard = await requireAuth(reqWith('expired-sess'));
|
||||
expect(guard.ok).toBe(false);
|
||||
if (!guard.ok) expect(guard.response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('resolves the seeded admin session to the local player row (joined on username)', async () => {
|
||||
const inst = await getGameDb();
|
||||
const sessionId = await createSession(inst, 'admin-kamilkrzywda');
|
||||
const guard = await requireAuth(reqWith(sessionId));
|
||||
expect(guard.ok).toBe(true);
|
||||
if (guard.ok) {
|
||||
expect(guard.user.username).toBe('kamilkrzywda');
|
||||
expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row
|
||||
expect(guard.player.cash).toBe(20_000); // starter economy from the seed
|
||||
}
|
||||
});
|
||||
|
||||
it('500s when the account has no players row (broken data, not a client error)', async () => {
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [
|
||||
hashPassword('whatever-123'),
|
||||
Date.now(),
|
||||
]);
|
||||
const sessionId = await createSession(inst, 'user-no-player');
|
||||
const guard = await requireAuth(reqWith(sessionId));
|
||||
expect(guard.ok).toBe(false);
|
||||
if (!guard.ok) {
|
||||
expect(guard.response.status).toBe(500);
|
||||
expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import type { PlayerRow } from './db/schema';
|
||||
|
||||
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
|
||||
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
|
||||
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
|
||||
// a single-origin dev tool surface, not a public auth system.
|
||||
|
||||
export const SESSION_HEADER = 'x-flight-session';
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
|
||||
|
||||
// Fixed digest compared against when the username is unknown, so every failed login runs one
|
||||
// full scrypt and the timing shape stays uniform (no user-existence oracle).
|
||||
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
|
||||
// 32-byte hash). Malformed digests never match.
|
||||
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||
const [saltHex, hashHex] = storedHash.split('$');
|
||||
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
|
||||
const computed = scryptSync(password, saltHex, 32).toString('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
|
||||
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
|
||||
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
|
||||
// verifies through verifyPassword().
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
|
||||
}
|
||||
|
||||
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
||||
// doesn't silently log everyone out mid-session.
|
||||
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
||||
const id = randomUUID();
|
||||
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
|
||||
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||
id,
|
||||
userId,
|
||||
Date.now() + SESSION_TTL_MS,
|
||||
]);
|
||||
await persist(inst);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Resolve a session id to a live (non-expired) user, or null.
|
||||
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
|
||||
const res = inst.raw.exec(
|
||||
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
|
||||
[sessionId, Date.now()],
|
||||
);
|
||||
const row = res[0]?.values?.[0];
|
||||
if (!row) return null;
|
||||
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
||||
}
|
||||
|
||||
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
||||
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
||||
const sessionId = req.headers.get(SESSION_HEADER);
|
||||
if (!sessionId) return null;
|
||||
return resolveSession(await getGameDb(), sessionId);
|
||||
}
|
||||
|
||||
export type AuthGuard =
|
||||
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
|
||||
| { ok: false; response: Response };
|
||||
|
||||
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
|
||||
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
|
||||
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
|
||||
// (register and the mint hook always create both together — a miss means broken data).
|
||||
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
|
||||
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||
const inst = await getGameDb();
|
||||
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
|
||||
const row = res[0]?.values?.[0];
|
||||
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
|
||||
return {
|
||||
ok: true,
|
||||
inst,
|
||||
user,
|
||||
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request): Promise<AuthGuard> {
|
||||
return finishAuth(await currentUser(req));
|
||||
}
|
||||
|
||||
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
|
||||
// ?session= query parameter (private infra; every other game route is header-only).
|
||||
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
|
||||
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
|
||||
if (!sessionId) return finishAuth(null);
|
||||
return finishAuth(await resolveSession(await getGameDb(), sessionId));
|
||||
}
|
||||
|
||||
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
|
||||
|
||||
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
|
||||
// No/expired session → 401, valid non-admin session → 403.
|
||||
export async function requireAdmin(req: Request): Promise<AdminGuard> {
|
||||
const user = await currentUser(req);
|
||||
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||
if (!user.admin) {
|
||||
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
|
||||
}
|
||||
return { ok: true, inst: await getGameDb(), user };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'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
|
||||
}
|
||||
}
|
||||
+11
-180
@@ -2,17 +2,12 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import initSqlJs from 'sql.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
|
||||
import { STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||||
import { openGameDb, persist } from './client';
|
||||
import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer';
|
||||
import * as schema from './schema';
|
||||
|
||||
// v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry.
|
||||
const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' };
|
||||
|
||||
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE planes (
|
||||
@@ -89,40 +84,6 @@ 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;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -149,13 +110,8 @@ describe('openGameDb', () => {
|
||||
expect(packages.length).toBeGreaterThanOrEqual(8);
|
||||
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
||||
|
||||
// v1.0.0 (ISS-105): the singleton economy is now a seeded local player row…
|
||||
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
// …and fresh DBs never create an `account` table.
|
||||
const tables = new Set(
|
||||
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||
);
|
||||
expect(tables.has('account')).toBe(false);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account?.cash).toBeGreaterThan(0);
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
|
||||
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
|
||||
@@ -170,25 +126,25 @@ describe('openGameDb', () => {
|
||||
const file = path.join(dir, 'reopen.db');
|
||||
const first = await openGameDb(file);
|
||||
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||
const player1 = await getLocalPlayer(first);
|
||||
const account1 = (await first.db.select().from(schema.account))[0];
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||
expect(packages2).toEqual(packages1);
|
||||
expect(await getLocalPlayer(second)).toEqual(player1);
|
||||
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
||||
});
|
||||
|
||||
it('persist() durably saves writes made after open', async () => {
|
||||
const file = path.join(dir, 'write.db');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
await updateLocalPlayerEconomy(inst, { cash: 12345 });
|
||||
await inst.db.update(schema.account).set({ cash: 12345 });
|
||||
await persist(inst);
|
||||
|
||||
// A fresh instance over the same file sees the update.
|
||||
const reloaded = await openGameDb(file);
|
||||
expect((await getLocalPlayer(reloaded)).cash).toBe(12345);
|
||||
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
|
||||
});
|
||||
|
||||
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
|
||||
@@ -205,8 +161,8 @@ describe('openGameDb', () => {
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Not re-seeded (the legacy account row was migrated into the local player)…
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(20000);
|
||||
// Not re-seeded (account row exists)…
|
||||
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
|
||||
// …but the new columns exist and are usable…
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane?.departureSimTime ?? null).toBeNull();
|
||||
@@ -238,8 +194,8 @@ describe('openGameDb', () => {
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Wallet (migrated into the local player) + sim clock survive byte-identical…
|
||||
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||
// Wallet + sim clock survive byte-identical…
|
||||
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
|
||||
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
||||
|
||||
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
||||
@@ -266,77 +222,6 @@ describe('openGameDb', () => {
|
||||
expect(cols.has('dest_icao')).toBe(true);
|
||||
});
|
||||
|
||||
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
|
||||
const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
|
||||
// The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(),
|
||||
// so it never verified; production DBs seeded by that version carry exactly this value.
|
||||
const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
|
||||
|
||||
it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
|
||||
const file = path.join(dir, 'admin-seed.db');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
rows = second.raw.exec('SELECT COUNT(*) FROM users');
|
||||
expect(Number(rows[0].values[0][0])).toBe(1);
|
||||
});
|
||||
|
||||
it('seeds the admin into a legacy DB that predates the users table', async () => {
|
||||
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(LEGACY_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||
const file = path.join(dir, 'legacy-admin.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
|
||||
// The game world is untouched by the seed (its account row migrated to the local player).
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(20000);
|
||||
});
|
||||
|
||||
it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => {
|
||||
// A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest.
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(`CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)`);
|
||||
raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [
|
||||
'admin-kamilkrzywda',
|
||||
'kamilkrzywda',
|
||||
LEGACY_HASH,
|
||||
Date.now(),
|
||||
]);
|
||||
const file = path.join(dir, 'admin-rotate.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
// First open heals the row to the current digest…
|
||||
const first = await openGameDb(file);
|
||||
let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
|
||||
|
||||
// …and a second open is a no-op (idempotent rotation).
|
||||
const second = await openGameDb(file);
|
||||
rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
|
||||
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
|
||||
});
|
||||
|
||||
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
||||
const file = path.join(dir, 'pre-v12-reopen.db');
|
||||
const SQL = await initSqlJs();
|
||||
@@ -359,58 +244,4 @@ describe('openGameDb', () => {
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('LEMD');
|
||||
});
|
||||
|
||||
it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => {
|
||||
// Minimal legacy file — just an `account` table; open() creates everything else from DDL.
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`);
|
||||
raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`);
|
||||
const file = path.join(dir, 'pre-v1.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
|
||||
let tables = new Set(
|
||||
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||
);
|
||||
expect(tables.has('account')).toBe(false);
|
||||
|
||||
// Second open is a no-op (idempotent): the row survives unchanged, still exactly one player.
|
||||
const second = await openGameDb(file);
|
||||
expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
|
||||
tables = new Set(
|
||||
second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||
);
|
||||
expect(tables.has('account')).toBe(false);
|
||||
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||
});
|
||||
|
||||
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
|
||||
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(PRE_OWNERSHIP_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
|
||||
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
|
||||
const file = path.join(dir, 'pre-ownership.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Every legacy plane is backfilled to the local player…
|
||||
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||
expect(owners).toEqual(['local', 'local']);
|
||||
// …and the world was NOT re-seeded (the players row already existed).
|
||||
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||
|
||||
// Second open is a no-op: an owner change made after the migration survives a reopen.
|
||||
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
|
||||
await persist(inst);
|
||||
const second = await openGameDb(file);
|
||||
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||
expect(owners2).toEqual(['local', 'someone-else']);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-79
@@ -5,7 +5,6 @@ import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
|
||||
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { buildSeed, mulberry32 } from '@/lib/game/seed';
|
||||
import { LOCAL_PLAYER } from './localPlayer';
|
||||
import * as schema from './schema';
|
||||
|
||||
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
|
||||
@@ -23,9 +22,6 @@ export interface GameDb {
|
||||
const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS planes (
|
||||
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,
|
||||
status TEXT NOT NULL,
|
||||
fuel REAL NOT NULL,
|
||||
@@ -55,12 +51,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
||||
created_real_ms INTEGER NOT NULL DEFAULT 0,
|
||||
slot_free_at_real_ms INTEGER
|
||||
);
|
||||
-- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local
|
||||
-- single-player row is seeded by seedEmpty() or migrated from account in open().
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
cash REAL NOT NULL,
|
||||
reputation REAL NOT NULL
|
||||
);
|
||||
@@ -71,46 +62,8 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the
|
||||
// operator's head — never in code, tests or comments; login verifies against this string.
|
||||
// v0.72.0: recomputed with exactly the same call as verifyPassword() — salt passed as a
|
||||
// plain string, scryptSync(pw, saltHex, 32) — because the v0.71.0 digest used different
|
||||
// parameters and real logins failed with 401.
|
||||
const ADMIN_SEED_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
|
||||
|
||||
// v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so
|
||||
// pre-existing production DBs gain exactly one admin row and never a duplicate.
|
||||
function seedAdminUser(raw: RawSqlJsDatabase): void {
|
||||
raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
|
||||
[ADMIN_SEED_HASH, Date.now()],
|
||||
);
|
||||
// v0.72.0: v0.71.0 seeded an incompatible digest into production DBs and the INSERT above
|
||||
// can't overwrite them (WHERE NOT EXISTS), so rotate any legacy row to the current digest
|
||||
// at boot — a no-op once healed, durable on the next persist().
|
||||
raw.run(
|
||||
`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`,
|
||||
[ADMIN_SEED_HASH, ADMIN_SEED_HASH],
|
||||
);
|
||||
}
|
||||
|
||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||
// already have everything from DDL above and this is a no-op for them.
|
||||
@@ -162,12 +115,6 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
||||
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
||||
}
|
||||
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
|
||||
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
|
||||
// constant default, which SQLite applies to every existing row at ALTER time).
|
||||
if (!cols('planes').has('owner_id')) {
|
||||
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||
@@ -197,17 +144,6 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
|
||||
raw.exec('DROP TABLE contracts');
|
||||
}
|
||||
|
||||
// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop
|
||||
// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have
|
||||
// `account`, so this runs exactly once per legacy file and is a no-op on later opens.
|
||||
function migrateAccountToPlayers(raw: RawSqlJsDatabase): void {
|
||||
raw.run(
|
||||
'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account',
|
||||
[LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username],
|
||||
);
|
||||
raw.exec('DROP TABLE account');
|
||||
}
|
||||
|
||||
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
||||
// of the real ./db/flight.db.
|
||||
export function defaultDbPath(): string {
|
||||
@@ -225,7 +161,6 @@ async function open(file: string): Promise<GameDb> {
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
migrateColumns(raw);
|
||||
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
|
||||
|
||||
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
||||
// without this it would keep the dead contracts table forever.
|
||||
@@ -235,18 +170,12 @@ async function open(file: string): Promise<GameDb> {
|
||||
if (tables.has('contracts')) {
|
||||
migrateContractsToPackages(raw);
|
||||
}
|
||||
// v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a
|
||||
// seeded local player and drop it. The players-empty guard makes this idempotent even if a file
|
||||
// somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file.
|
||||
const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0);
|
||||
if (tables.has('account') && playerCount === 0) {
|
||||
migrateAccountToPlayers(raw);
|
||||
}
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
// First-run seed: only when the DB is empty (no player row yet).
|
||||
if (playerCount === 0 && !tables.has('account')) {
|
||||
// First-run seed: only when the DB is empty (no account row yet).
|
||||
const res = raw.exec('SELECT COUNT(*) FROM account');
|
||||
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
||||
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
|
||||
await seedEmpty(instance);
|
||||
}
|
||||
@@ -276,11 +205,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
||||
|
||||
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||
const { queue, ...planeCols } = data.plane;
|
||||
// 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.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
|
||||
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.
|
||||
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
|
||||
await instance.db.insert(schema.account).values(data.account);
|
||||
await instance.db.insert(schema.simState).values(data.simState);
|
||||
await persist(instance);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { GameDb } from './client';
|
||||
import * as schema from './schema';
|
||||
|
||||
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
||||
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
|
||||
// these 'local' helpers remain ONLY for world-level reads with no acting session (the engine's
|
||||
// shared-pool spawn quality — pools are SHARED state, so the seeded row sets job quality) and
|
||||
// env-gated /api/test/* admin hooks. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player.
|
||||
|
||||
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||
|
||||
export async function getLocalPlayer(inst: GameDb): Promise<schema.PlayerRow> {
|
||||
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||
if (!row) throw new Error('local player row missing');
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateLocalPlayerEconomy(
|
||||
inst: GameDb,
|
||||
patch: { cash?: number; reputation?: number },
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||
}
|
||||
|
||||
// v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and
|
||||
// session-scoped snapshots thread the acting player's id through and read/write THAT row; the
|
||||
// 'local' helpers above are reserved for world-level reads with no acting session.
|
||||
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
|
||||
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
|
||||
if (!row) throw new Error(`player ${id} missing`);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updatePlayerEconomy(
|
||||
inst: GameDb,
|
||||
id: string,
|
||||
patch: { cash?: number; reputation?: number },
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||
}
|
||||
+5
-34
@@ -6,10 +6,6 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const planes = sqliteTable('planes', {
|
||||
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(),
|
||||
// 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'.
|
||||
@@ -57,26 +53,21 @@ export const packages = sqliteTable('packages', {
|
||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
|
||||
planeId: text('plane_id'),
|
||||
createdSimTime: integer('created_sim_time').notNull(),
|
||||
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
|
||||
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
|
||||
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
|
||||
createdRealMs: integer('created_real_ms').notNull().default(0),
|
||||
// Real-time epoch ms when this (origin, band) slot was last taken — loaded (v0.65.0). The pool
|
||||
// Real-time epoch ms when this (origin, band) slot was last taken — loaded or ignored (v0.65.0). The pool
|
||||
// spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open
|
||||
// jobs set nothing: an untaken job's slot respawns immediately.
|
||||
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
|
||||
});
|
||||
|
||||
// v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local
|
||||
// single-player row (id 'local') is seeded on first run / migrated from `account` by open();
|
||||
// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only.
|
||||
export const players = sqliteTable('players', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
username: text('username').notNull(),
|
||||
export const account = sqliteTable('account', {
|
||||
cash: real('cash').notNull(),
|
||||
reputation: real('reputation').notNull(),
|
||||
});
|
||||
@@ -94,27 +85,7 @@ export const simState = sqliteTable('sim_state', {
|
||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||
});
|
||||
|
||||
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
username: text('username').notNull().unique(),
|
||||
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
export type PackageRow = typeof packages.$inferSelect;
|
||||
export type PlayerRow = typeof players.$inferSelect;
|
||||
export type AccountRow = typeof account.$inferSelect;
|
||||
export type SimStateRow = typeof simState.$inferSelect;
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
@@ -25,8 +25,6 @@ const basePlane = (id: string): PlaneState => ({
|
||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||
ownerName: 'kamilkrzywda',
|
||||
});
|
||||
|
||||
const basePackage = (id: string): PackageState => ({
|
||||
@@ -46,7 +44,6 @@ const basePackage = (id: string): PackageState => ({
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||
});
|
||||
|
||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||
@@ -67,7 +64,7 @@ describe('diffSnapshots', () => {
|
||||
expect(diffSnapshots(prev, next)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for the first snapshot (no baseline to diff against)', () => {
|
||||
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => {
|
||||
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
|
||||
expect(diffSnapshots(null, next)).toEqual([]);
|
||||
});
|
||||
@@ -116,6 +113,14 @@ describe('diffSnapshots', () => {
|
||||
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
|
||||
});
|
||||
|
||||
it('emits a new-package event for ids only in the next snapshot', () => {
|
||||
const prev = snapshot({ packages: [basePackage('pkg-1')] });
|
||||
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
|
||||
expect(diffSnapshots(prev, next)).toEqual([
|
||||
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits bought/sold events when the plane id set changes (with the model name)', () => {
|
||||
const bought = diffSnapshots(
|
||||
snapshot(),
|
||||
|
||||
+9
-4
@@ -15,7 +15,8 @@ export interface GameEvent {
|
||||
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
|
||||
|
||||
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
|
||||
// No baseline yet (first snapshot after load) — nothing to diff against, so no events.
|
||||
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a
|
||||
// "new package" event for every existing row would spam the stack on page load.
|
||||
if (prev === null) return [];
|
||||
|
||||
const events: GameEvent[] = [];
|
||||
@@ -39,12 +40,16 @@ export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot):
|
||||
}
|
||||
}
|
||||
|
||||
// Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
|
||||
// (open|loaded → expired). Ids that appear for the first time emit nothing.
|
||||
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired
|
||||
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never
|
||||
// deleted, so an id appearing is always a genuinely new one).
|
||||
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
|
||||
for (const pkg of next.packages) {
|
||||
const before = prevPackages.get(pkg.id);
|
||||
if (!before) continue;
|
||||
if (!before) {
|
||||
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
|
||||
continue;
|
||||
}
|
||||
if (before.status === 'loaded' && pkg.status === 'delivered') {
|
||||
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
|
||||
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
|
||||
|
||||
+147
-198
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
@@ -22,13 +21,14 @@ import {
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
|
||||
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
||||
import {
|
||||
abandonPackage,
|
||||
buyPlane,
|
||||
cancelQueuedLeg,
|
||||
flyPlane,
|
||||
GameActionError,
|
||||
ignorePackage,
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
repairPlane,
|
||||
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
|
||||
const inst = await freshDb('load-ok.db');
|
||||
await insertOpenPackage(inst);
|
||||
|
||||
await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
|
||||
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
||||
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||
expect(row.status).toBe('loaded');
|
||||
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
|
||||
const inst = await freshDb(`load-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
|
||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unknown package id with 404', async () => {
|
||||
const inst = await freshDb('load-punknown.db');
|
||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
|
||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('load-ghost.db');
|
||||
await insertOpenPackage(inst);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
||||
});
|
||||
|
||||
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 inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
||||
});
|
||||
|
||||
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 expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||
});
|
||||
|
||||
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-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('flyPlane', () => {
|
||||
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||
const inst = await freshDb('fly-ok.db');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -188,38 +188,38 @@ describe('flyPlane', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('fly-ghost.db');
|
||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
||||
});
|
||||
|
||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||
const inst = await freshDb('fly-unknownicao.db');
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
||||
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
|
||||
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /broken \(health 0\)/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // still grounded…
|
||||
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and no cash left the account
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
|
||||
});
|
||||
|
||||
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
|
||||
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
||||
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // no 409 — worn is not broken
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -250,10 +250,10 @@ describe('flyPlane', () => {
|
||||
const startFuel = Math.round(required / 2); // partial tank below the requirement
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
|
||||
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
await inst.db.update(schema.account).set({ reputation: 30 });
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
||||
@@ -264,22 +264,22 @@ describe('flyPlane', () => {
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||
});
|
||||
|
||||
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => {
|
||||
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and free
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free
|
||||
});
|
||||
|
||||
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
|
||||
@@ -287,9 +287,9 @@ describe('flyPlane', () => {
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||
await inst.db.update(schema.account).set({ cash: 0 });
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /insufficient funds/i);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // still grounded…
|
||||
@@ -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.
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD', 'local'), 409, /insufficient fuel/i);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||
|
||||
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||
const inst = await freshDb('fly-consume.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -321,10 +321,10 @@ describe('flyPlane', () => {
|
||||
|
||||
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||
const inst = await freshDb('fly-skipahead.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
|
||||
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -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 () => {
|
||||
const inst = await freshDb('qleg-ok.db');
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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 () => {
|
||||
const inst = await freshDb('qleg-ghost.db');
|
||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
||||
});
|
||||
|
||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||
const inst = await freshDb('qleg-unknownicao.db');
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||
const inst = await freshDb('qleg-dup.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||
@@ -386,7 +386,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||
@@ -394,8 +394,8 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
|
||||
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||
const inst = await freshDb('qleg-dupother.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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)', () => {
|
||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||
const inst = await freshDb('qcancel-ok.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
||||
|
||||
await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
|
||||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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 () => {
|
||||
const inst = await freshDb('qcancel-oob.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
||||
});
|
||||
|
||||
it('rejects cancelling from an empty queue with 400', async () => {
|
||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('qcancel-ghost.db');
|
||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,7 +442,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await freshDb(name);
|
||||
// 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 loadPackage(inst, 'p-ab', 'plane-starter', 'local');
|
||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||
if (status === 'expired') {
|
||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||||
@@ -452,23 +452,23 @@ describe('abandonPackage', () => {
|
||||
|
||||
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
|
||||
const inst = await setupLoaded('ab-start.db');
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
|
||||
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];
|
||||
expect(row.status).toBe('abandoned');
|
||||
expect(row.planeId).toBeNull(); // slot freed
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
|
||||
});
|
||||
|
||||
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||||
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
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
|
||||
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||||
});
|
||||
@@ -481,31 +481,31 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded(name);
|
||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||
}
|
||||
});
|
||||
|
||||
it('is free for an already-expired package', async () => {
|
||||
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
|
||||
expect(penalty).toBe(0);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||
expect(row.status).toBe('abandoned');
|
||||
});
|
||||
|
||||
it('floors cash at €0 — no debt', async () => {
|
||||
const inst = await setupLoaded('ab-floor.db');
|
||||
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
||||
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
|
||||
expect(penalty).toBe(150);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||||
@@ -514,32 +514,62 @@ describe('abandonPackage', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
|
||||
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
||||
});
|
||||
|
||||
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||
const inst = await freshDb(`ab-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
|
||||
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('ab-unknown.db');
|
||||
await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
|
||||
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignorePackage', () => {
|
||||
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
|
||||
const inst = await freshDb('ig-open.db');
|
||||
await insertOpenPackage(inst, 'p-ig');
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await ignorePackage(inst, 'p-ig');
|
||||
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
|
||||
expect(row.status).toBe('ignored');
|
||||
expect(row.planeId).toBeNull(); // was never bound
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
|
||||
});
|
||||
|
||||
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
|
||||
'rejects a %s package with 409 (must be open)',
|
||||
async (status) => {
|
||||
const inst = await freshDb(`ig-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('ig-unknown.db');
|
||||
await expectActionError(ignorePackage(inst, 'nope'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buyPlane', () => {
|
||||
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
||||
const inst = await freshDb('buy-ok.db');
|
||||
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||
await inst.db.update(schema.account).set({ cash: 500_000 });
|
||||
|
||||
const before = (await inst.db.select().from(schema.planes)).length;
|
||||
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||
const { planeId } = await buyPlane(inst, 'atr72');
|
||||
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||
|
||||
const planes = await inst.db.select().from(schema.planes);
|
||||
expect(planes.length).toBe(before + 1);
|
||||
@@ -557,44 +587,44 @@ describe('buyPlane', () => {
|
||||
|
||||
it('rejects an unknown catalog model with 400', async () => {
|
||||
const inst = await freshDb('buy-unknown.db');
|
||||
await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
|
||||
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
|
||||
});
|
||||
|
||||
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
|
||||
await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||||
const inst = await freshDb('buy-rep.db');
|
||||
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
|
||||
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
||||
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
|
||||
|
||||
const before = (await inst.db.select().from(schema.planes)).length;
|
||||
await buyPlane(inst, 'atr72', 'local');
|
||||
await buyPlane(inst, 'atr72');
|
||||
|
||||
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||||
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
|
||||
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
|
||||
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
|
||||
const inst = await freshDb('buy-boundary.db');
|
||||
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||||
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
|
||||
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
|
||||
|
||||
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||
const { planeId } = await buyPlane(inst, 'atr72');
|
||||
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||
|
||||
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
||||
await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
|
||||
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -603,16 +633,16 @@ describe('sellPlane', () => {
|
||||
const inst = await freshDb('sell-ok.db');
|
||||
// A second plane so the starter is not "the last remaining one".
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { payout } = await sellPlane(inst, 'plane-extra', 'local');
|
||||
const { payout } = await sellPlane(inst, 'plane-extra');
|
||||
|
||||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
|
||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||||
expect(ids).not.toContain('plane-extra'); // row deleted
|
||||
expect(ids).toContain('plane-starter');
|
||||
@@ -621,22 +651,22 @@ describe('sellPlane', () => {
|
||||
it('rejects selling a flying plane with 409', async () => {
|
||||
const inst = await freshDb('sell-flying.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||||
});
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
||||
});
|
||||
|
||||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||
const inst = await freshDb('sell-cargo.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
||||
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
|
||||
});
|
||||
@@ -644,24 +674,24 @@ describe('sellPlane', () => {
|
||||
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||
const inst = await freshDb('sell-queue.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-extra', 'LEMD');
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
|
||||
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||
});
|
||||
|
||||
it('rejects selling the last remaining plane with 409', async () => {
|
||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('sell-unknown.db');
|
||||
await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
|
||||
await expectActionError(sellPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -669,31 +699,31 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
|
||||
const inst = await freshDb('repair-ok.db');
|
||||
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 inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(REPAIR_COST);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||
});
|
||||
|
||||
it('is a free no-op when health is already full', async () => {
|
||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // not grounded…
|
||||
expect(plane.health).toBe(100);
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
@@ -702,7 +732,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('rejects a plane that is already repairing with 409', async () => {
|
||||
@@ -711,116 +741,35 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||
const inst = await freshDb('repair-blocks.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await repairPlane(inst, 'plane-starter', 'local');
|
||||
await repairPlane(inst, 'plane-starter');
|
||||
|
||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
});
|
||||
|
||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||
const inst = await freshDb('repair-poor.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
||||
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('repair-unknown.db');
|
||||
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
|
||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-60
@@ -1,8 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { persist, type GameDb } from '@/lib/db/client';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
@@ -35,7 +33,6 @@ import { HUB_ICAO } from './seed';
|
||||
// 404 — unknown entity id
|
||||
// 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)
|
||||
// 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)
|
||||
|
||||
export class GameActionError extends Error {
|
||||
@@ -55,18 +52,13 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
|
||||
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}) ---
|
||||
|
||||
// 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
|
||||
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
||||
// delivered/abandoned).
|
||||
export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
|
||||
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
||||
const [pkgRows, planeRows] = await Promise.all([
|
||||
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
||||
@@ -78,7 +70,6 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
||||
}
|
||||
const plane = planeRows[0];
|
||||
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') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
||||
}
|
||||
@@ -118,11 +109,10 @@ 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
|
||||
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
||||
// 409s before any refuel math, so fuel and cash stay untouched.
|
||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
|
||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = planeRows[0];
|
||||
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') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
||||
}
|
||||
@@ -150,11 +140,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string,
|
||||
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||
const liters = Math.max(0, target - plane.fuel);
|
||||
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
|
||||
const player = await getPlayer(inst, playerId);
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost > 0 && player.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost > 0 && account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
|
||||
if (requiredL > target) {
|
||||
@@ -183,7 +173,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string,
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
await persist(inst);
|
||||
}
|
||||
@@ -195,11 +185,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string,
|
||||
// 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
|
||||
// in the airport dataset.
|
||||
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
|
||||
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = planeRows[0];
|
||||
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();
|
||||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||||
@@ -226,11 +215,10 @@ 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
|
||||
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||
// known plane, integer index within [0, queue.length).
|
||||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
|
||||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
|
||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = planeRows[0];
|
||||
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);
|
||||
if (index < 0 || index >= queue.length) {
|
||||
@@ -247,7 +235,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
|
||||
// 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
|
||||
// goes below €0. The slot is freed immediately.
|
||||
export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
|
||||
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
|
||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||
const pkg = pkgRows[0];
|
||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||
@@ -259,7 +247,6 @@ export async function abandonPackage(inst: GameDb, packageId: string, playerId:
|
||||
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
||||
: undefined;
|
||||
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') {
|
||||
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
||||
}
|
||||
@@ -268,29 +255,46 @@ export async function abandonPackage(inst: GameDb, packageId: string, playerId:
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
const penalty = abandonPenalty(pkg, sim.clockMs);
|
||||
|
||||
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
|
||||
const player = await getPlayer(inst, playerId);
|
||||
await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
|
||||
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
||||
await persist(inst);
|
||||
return { penalty };
|
||||
}
|
||||
|
||||
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
|
||||
|
||||
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
|
||||
// changes. The pool spawner refills this (origin, band) slot after its refill delay —
|
||||
// ignoring starts that cooldown via slotFreeAtRealMs (v0.65.0). 404 unknown id; 409 unless open.
|
||||
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
|
||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||
const pkg = pkgRows[0];
|
||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||
if (pkg.status !== 'open') {
|
||||
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: Date.now() }).where(eq(schema.packages.id, packageId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||
|
||||
// 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
|
||||
// at the home base (EDDF).
|
||||
export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
|
||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
||||
const type = PLANE_TYPES[model];
|
||||
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
||||
|
||||
// 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);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
// 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;
|
||||
if (player.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
|
||||
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||||
if (account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
@@ -300,7 +304,6 @@ export async function buyPlane(inst: GameDb, model: string, playerId: string): P
|
||||
const planeId = randomUUID();
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: planeId,
|
||||
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
|
||||
model,
|
||||
status: 'available',
|
||||
fuel: type.maxFuel, // full tank on delivery
|
||||
@@ -312,7 +315,7 @@ export async function buyPlane(inst: GameDb, model: string, playerId: string): P
|
||||
departureSimTime: null,
|
||||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||
});
|
||||
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { planeId };
|
||||
}
|
||||
@@ -321,11 +324,10 @@ export async function buyPlane(inst: GameDb, model: string, playerId: string): P
|
||||
|
||||
// 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.
|
||||
export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
|
||||
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
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') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
||||
}
|
||||
@@ -344,18 +346,17 @@ export async function sellPlane(inst: GameDb, planeId: string, playerId: string)
|
||||
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||
}
|
||||
|
||||
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
|
||||
// players' planes exist in the world, and selling one of two of yours is fine.
|
||||
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
|
||||
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||
const all = await inst.db.select().from(schema.planes);
|
||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
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;
|
||||
|
||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const player = await getPlayer(inst, playerId);
|
||||
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { payout };
|
||||
}
|
||||
@@ -368,11 +369,10 @@ export async function sellPlane(inst: GameDb, planeId: string, playerId: string)
|
||||
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||||
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||||
// plane; 409 not grounded / insufficient funds.
|
||||
export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
|
||||
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
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') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||
}
|
||||
@@ -381,17 +381,17 @@ export async function repairPlane(inst: GameDb, planeId: string, playerId: strin
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
|
||||
const player = await getPlayer(inst, playerId);
|
||||
if (player.cash < REPAIR_COST) {
|
||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (account.cash < REPAIR_COST) {
|
||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { cost: REPAIR_COST };
|
||||
}
|
||||
@@ -402,17 +402,10 @@ export async function repairPlane(inst: GameDb, planeId: string, playerId: strin
|
||||
// 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
|
||||
// its own status + {error}; anything unexpected is a 500.
|
||||
// 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;
|
||||
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
|
||||
try {
|
||||
await runAction((inst) => fn(inst, auth.player.id));
|
||||
// v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`.
|
||||
return Response.json(await getStateSnapshot(auth.player.id));
|
||||
await runAction(fn);
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
if (err instanceof GameActionError) {
|
||||
return Response.json({ error: err.message }, { status: err.status });
|
||||
|
||||
+12
-19
@@ -122,7 +122,7 @@ function envMs(name: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
|
||||
// Refill cooldown after a job is TAKEN (loaded): the slot stays empty this long so the
|
||||
// Refill cooldown after a job is TAKEN (loaded or ignored): the slot stays empty this long so the
|
||||
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
|
||||
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
|
||||
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
|
||||
@@ -360,14 +360,19 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
|
||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||
}
|
||||
|
||||
// v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
|
||||
// it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
|
||||
// caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
|
||||
export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
|
||||
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 type = PLANE_TYPES[STARTER_MODEL];
|
||||
const hub = pickHub(airports);
|
||||
return {
|
||||
id,
|
||||
|
||||
const plane: SeedPlane = {
|
||||
id: 'plane-starter',
|
||||
model: STARTER_MODEL,
|
||||
status: 'available',
|
||||
fuel: type.maxFuel, // starts full
|
||||
@@ -379,18 +384,6 @@ export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlan
|
||||
queue: [], // no queued legs on first run (v0.25.0)
|
||||
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
|
||||
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
||||
|
||||
@@ -20,7 +20,6 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
||||
status,
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||
});
|
||||
|
||||
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||
@@ -41,6 +40,7 @@ describe('sseFrame', () => {
|
||||
pkg('loaded', 'p-loaded'),
|
||||
pkg('abandoned', 'p-abandoned'),
|
||||
pkg('expired', 'p-expired'),
|
||||
pkg('ignored', 'p-ignored'),
|
||||
];
|
||||
const frame = sseFrame(snapshot(all));
|
||||
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
|
||||
|
||||
+10
-38
@@ -2,7 +2,6 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { getPlayer } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||
@@ -37,10 +36,6 @@ export interface PlaneState {
|
||||
health: number;
|
||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||
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
|
||||
@@ -86,15 +81,13 @@ export interface PackageState {
|
||||
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
|
||||
distanceKm: number;
|
||||
deadlineSimTime: number;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
|
||||
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
||||
expiresRealMs: number | null;
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
@@ -113,7 +106,7 @@ export interface StateSnapshot {
|
||||
|
||||
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
|
||||
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
|
||||
// Terminal history (delivered/abandoned) is unbounded in the DB and never read by the
|
||||
// Terminal history (delivered/abandoned/ignored) is unbounded in the DB and never read by the
|
||||
// client, so it stays out of every pushed frame; /api/state and action responses keep the full set.
|
||||
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
|
||||
|
||||
@@ -121,29 +114,20 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
|
||||
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
|
||||
}
|
||||
|
||||
// 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 { db } = inst;
|
||||
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const { db } = await getGameDb();
|
||||
// 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
|
||||
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
||||
const [player, simRows, planeRows, packageRows, playerRows] = await Promise.all([
|
||||
getPlayer(inst, playerId),
|
||||
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
|
||||
db.select().from(schema.account),
|
||||
db.select().from(schema.simState),
|
||||
db.select().from(schema.planes),
|
||||
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 accountRow = accountRows[0];
|
||||
const simRow = simRows[0];
|
||||
if (!simRow) throw new Error('game state missing sim_state row');
|
||||
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row');
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
||||
@@ -161,11 +145,6 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
// body entirely, so clients never see jobs they could not dispatch.
|
||||
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]));
|
||||
|
||||
// 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.
|
||||
const livePlane = (p: schema.PlaneRow) => {
|
||||
@@ -191,7 +170,7 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
};
|
||||
|
||||
return {
|
||||
account: { cash: player.cash, reputation: player.reputation },
|
||||
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
||||
simState: { clockMs: simRow.clockMs },
|
||||
catalog: catalogEntries(),
|
||||
fuelPricePerL: FUEL_PRICE_PER_L,
|
||||
@@ -214,8 +193,6 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
queue: parseIcaoQueue(p.queue),
|
||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||
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
|
||||
};
|
||||
}),
|
||||
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
||||
@@ -235,11 +212,6 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
planeId: p.planeId,
|
||||
createdSimTime: p.createdSimTime,
|
||||
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;
|
||||
})(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
+28
-28
@@ -55,8 +55,8 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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: [], 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||
@@ -83,7 +83,7 @@ describe('planesGeoJson', () => {
|
||||
snapshot({
|
||||
catalog: [],
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -95,7 +95,7 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
||||
@@ -113,7 +113,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -127,7 +127,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -148,7 +148,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -161,7 +161,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -173,7 +173,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -185,7 +185,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -197,7 +197,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -217,7 +217,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -229,7 +229,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -246,7 +246,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -258,8 +258,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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: [], 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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -273,7 +273,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
// 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -288,7 +288,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', () => {
|
||||
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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -298,7 +298,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', () => {
|
||||
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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -308,7 +308,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
it('draws nothing for a flying plane with an empty queue', () => {
|
||||
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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -319,7 +319,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
// 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -336,8 +336,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', () => {
|
||||
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'], 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -347,9 +347,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', () => {
|
||||
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'], 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: [], 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -491,7 +491,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°.
|
||||
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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ 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'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -529,7 +529,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 snap = snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
});
|
||||
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
||||
|
||||
+11
-69
@@ -3,24 +3,19 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the eighteen public endpoints (4 read + 8 player actions + 4 auth + 2 admin CRUD)', () => {
|
||||
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/admin/tables', // v0.71.0 — admin panel
|
||||
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/login', // v0.71.0 — admin panel auth
|
||||
'/api/logout', // v0.71.0
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/me', // v0.71.0
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue', // 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/register', // v1.1.0 (ISS-107) — self-service account creation
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
]);
|
||||
@@ -128,8 +123,6 @@ describe('OpenAPI spec', () => {
|
||||
'lon',
|
||||
'maxFuel',
|
||||
'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
|
||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||
'status',
|
||||
@@ -139,7 +132,6 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(pkg).sort()).toEqual([
|
||||
'cargoCount',
|
||||
'cargoType',
|
||||
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||
'createdSimTime',
|
||||
'deadlineSimTime',
|
||||
'destIcao',
|
||||
@@ -161,7 +153,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
|
||||
|
||||
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
|
||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
|
||||
});
|
||||
|
||||
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
@@ -188,17 +180,16 @@ describe('OpenAPI spec', () => {
|
||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Error shapes per endpoint.
|
||||
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
|
||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
|
||||
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', '403', '404']); // v0.25.0
|
||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '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/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['403', '404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||
|
||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
|
||||
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
@@ -252,53 +243,4 @@ describe('OpenAPI spec', () => {
|
||||
expect(sell.required).toEqual(['planeId']);
|
||||
expect(sell.properties.planeId.type).toBe('string');
|
||||
});
|
||||
|
||||
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
|
||||
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
|
||||
|
||||
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
|
||||
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
|
||||
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
|
||||
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
|
||||
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
|
||||
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
|
||||
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
|
||||
}
|
||||
|
||||
// Success shapes.
|
||||
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
|
||||
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
|
||||
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
|
||||
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
|
||||
expect(tables.type).toBe('array');
|
||||
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
|
||||
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
|
||||
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
|
||||
|
||||
// Request bodies + path parameter.
|
||||
const bodyRef = (p: string, m: string) =>
|
||||
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
|
||||
?.schema.$ref;
|
||||
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
|
||||
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
|
||||
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
|
||||
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
|
||||
}
|
||||
|
||||
// Request schema shapes.
|
||||
const login = openApiSpec.components.schemas.LoginRequest;
|
||||
expect(login.required).toEqual(['username', 'password']);
|
||||
expect(login.properties.username.type).toBe('string');
|
||||
expect(login.properties.password.type).toBe('string');
|
||||
const sessionUser = openApiSpec.components.schemas.SessionUser;
|
||||
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
|
||||
expect(sessionUser.properties.admin.type).toBe('boolean');
|
||||
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
|
||||
expect(updateRow.required).toEqual(['rowid', 'values']);
|
||||
expect(updateRow.properties.rowid.type).toBe('integer');
|
||||
});
|
||||
});
|
||||
|
||||
+28
-399
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
||||
info: {
|
||||
title: 'Flight Ops API',
|
||||
description:
|
||||
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||
// Sourced from package.json so it stays in sync with every version bump.
|
||||
version: pkg.version,
|
||||
},
|
||||
@@ -110,10 +110,6 @@ export const openApiSpec = {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||
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: {
|
||||
description: 'Unknown package id or plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -140,10 +136,6 @@ export const openApiSpec = {
|
||||
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
|
||||
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: {
|
||||
description: 'Unknown package id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -155,6 +147,31 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/packages/{id}/ignore': {
|
||||
post: {
|
||||
summary: 'Ignore an open job',
|
||||
description:
|
||||
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
|
||||
operationId: 'ignorePackage',
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown package id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Package not "open"',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/fly': {
|
||||
post: {
|
||||
summary: 'Fly a plane to an airport',
|
||||
@@ -182,10 +199,6 @@ export const openApiSpec = {
|
||||
'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' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -225,10 +238,6 @@ export const openApiSpec = {
|
||||
'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' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -268,10 +277,6 @@ export const openApiSpec = {
|
||||
'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' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -332,10 +337,6 @@ export const openApiSpec = {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||
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: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -361,10 +362,6 @@ 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)',
|
||||
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: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
@@ -376,274 +373,6 @@ 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.
|
||||
'/api/login': {
|
||||
post: {
|
||||
summary: 'Log in',
|
||||
description:
|
||||
'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.',
|
||||
operationId: 'login',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/LoginRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Authenticated — new session id + the user',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Unknown username or wrong password',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/logout': {
|
||||
post: {
|
||||
summary: 'Log out',
|
||||
description:
|
||||
'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.',
|
||||
operationId: 'logout',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Session deleted (or was already gone)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing X-Flight-Session header',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/me': {
|
||||
get: {
|
||||
summary: 'Current user',
|
||||
description:
|
||||
'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.',
|
||||
operationId: 'getMe',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The authenticated user',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/admin/tables': {
|
||||
get: {
|
||||
summary: 'List database tables (admin)',
|
||||
description:
|
||||
'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.',
|
||||
operationId: 'listAdminTables',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The table names',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/admin/tables/{name}/rows': {
|
||||
get: {
|
||||
summary: 'Read table rows (admin)',
|
||||
description:
|
||||
'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.',
|
||||
operationId: 'listAdminRows',
|
||||
parameters: [
|
||||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Column headers + rows (each row carries its __rowid)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'limit is not a positive integer',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
summary: 'Update a row (admin)',
|
||||
description:
|
||||
'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.',
|
||||
operationId: 'updateAdminRow',
|
||||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/UpdateRowRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Updated',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body, non-integer rowid, unknown column, or empty values',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
summary: 'Insert a row (admin)',
|
||||
description:
|
||||
'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.',
|
||||
operationId: 'insertAdminRow',
|
||||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/InsertRowRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Inserted — the new rowid',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body, unknown column, or empty values',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Database constraint violation (NOT NULL / UNIQUE)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete a row (admin)',
|
||||
description:
|
||||
'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.',
|
||||
operationId: 'deleteAdminRow',
|
||||
parameters: [
|
||||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||
{ name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Deleted (or was already gone)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Missing or non-integer rowid query parameter',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -792,101 +521,6 @@ export const openApiSpec = {
|
||||
},
|
||||
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.
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', description: 'Account username (non-empty).' },
|
||||
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
},
|
||||
SessionUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
|
||||
},
|
||||
required: ['username', 'admin'],
|
||||
},
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
|
||||
user: { $ref: '#/components/schemas/SessionUser' },
|
||||
},
|
||||
required: ['sessionId', 'user'],
|
||||
},
|
||||
OkResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { const: true },
|
||||
},
|
||||
required: ['ok'],
|
||||
},
|
||||
AdminTable: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
AdminRowsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
columns: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
|
||||
},
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
|
||||
},
|
||||
},
|
||||
required: ['columns', 'rows'],
|
||||
},
|
||||
UpdateRowRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
|
||||
},
|
||||
},
|
||||
required: ['rowid', 'values'],
|
||||
},
|
||||
InsertRowRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: 'Exactly the columns to set; others take their defaults or NULL.',
|
||||
},
|
||||
},
|
||||
required: ['values'],
|
||||
},
|
||||
InsertRowResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { const: true },
|
||||
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
|
||||
},
|
||||
required: ['ok', 'rowid'],
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -934,8 +568,6 @@ export const openApiSpec = {
|
||||
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.',
|
||||
},
|
||||
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: [
|
||||
'id',
|
||||
@@ -953,8 +585,6 @@ export const openApiSpec = {
|
||||
'queue',
|
||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
|
||||
'ownerName', // v1.4.0 (ISS-112) — owner's username
|
||||
],
|
||||
},
|
||||
PackageState: {
|
||||
@@ -974,11 +604,11 @@ export const openApiSpec = {
|
||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
|
||||
planeId: { type: ['string', 'null'] },
|
||||
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.' },
|
||||
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: [
|
||||
'id',
|
||||
@@ -996,7 +626,6 @@ export const openApiSpec = {
|
||||
'planeId',
|
||||
'createdSimTime',
|
||||
'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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+37
-108
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
@@ -153,6 +152,7 @@ describe('expireLoadedPackages (sim clock)', () => {
|
||||
{ id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
||||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||
]);
|
||||
|
||||
const n = await expireLoadedPackages(inst, clock);
|
||||
@@ -163,6 +163,7 @@ describe('expireLoadedPackages (sim clock)', () => {
|
||||
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
|
||||
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
|
||||
});
|
||||
|
||||
it('a deadline exactly at the clock counts as expired', async () => {
|
||||
@@ -229,20 +230,20 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
|
||||
it('does nothing before the arrival time', async () => {
|
||||
const { inst, km, type } = await setupFlight('deliver-early.db');
|
||||
const playerBefore = (await getLocalPlayer(inst));
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(0);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
const player = (await getLocalPlayer(inst));
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(player.cash).toBe(playerBefore.cash); // no award before landing
|
||||
expect(account.cash).toBe(accountBefore.cash); // no award before landing
|
||||
});
|
||||
|
||||
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
|
||||
const playerBefore = (await getLocalPlayer(inst));
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
// Three packages on the plane: one in-deadline for TO, one expired for TO,
|
||||
// one loaded for a DIFFERENT airport (stays bound for a later leg).
|
||||
@@ -275,23 +276,23 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo…
|
||||
expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // ONLY p-pay paid out
|
||||
expect(player.reputation).toBeCloseTo(
|
||||
Math.round((playerBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
||||
9,
|
||||
);
|
||||
|
||||
// A later tick must not award again — the plane is no longer 'flying'.
|
||||
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
|
||||
const playerAfter = (await getLocalPlayer(inst));
|
||||
expect(playerAfter.cash).toBe(player.cash);
|
||||
expect(playerAfter.reputation).toBe(player.reputation);
|
||||
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(accountAfter.cash).toBe(account.cash);
|
||||
expect(accountAfter.reputation).toBe(account.reputation);
|
||||
});
|
||||
|
||||
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
|
||||
const { inst, km } = await setupFlight('deliver-multi.db');
|
||||
const playerBefore = (await getLocalPlayer(inst));
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
await inst.db.insert(schema.packages).values([
|
||||
{ id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
@@ -301,17 +302,17 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
expect(player.cash).toBeCloseTo(playerBefore.cash + 3_500, 9); // both rewards
|
||||
expect(player.reputation).toBeCloseTo(
|
||||
Math.round((playerBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
|
||||
9,
|
||||
); // rep per delivered package
|
||||
});
|
||||
|
||||
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
|
||||
const { inst, km } = await setupFlight('deliver-special.db');
|
||||
const playerBefore = (await getLocalPlayer(inst));
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
await inst.db.insert(schema.packages).values([
|
||||
{ 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 },
|
||||
@@ -320,11 +321,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9);
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
|
||||
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
|
||||
expect(player.reputation).toBeCloseTo(
|
||||
Math.round((playerBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
|
||||
9,
|
||||
);
|
||||
});
|
||||
@@ -382,7 +383,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
@@ -403,7 +404,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
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(playerBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -413,21 +414,21 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order
|
||||
expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
expect(player.cash).toBeCloseTo(playerBefore.cash - expectedCost, 9); // exactly the rep-discounted cost
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost
|
||||
});
|
||||
|
||||
it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db');
|
||||
|
||||
// Same empty-tank landing as above, but the player is broke — the top-up is unaffordable.
|
||||
// Same empty-tank landing as above, but the account is broke — the top-up is unaffordable.
|
||||
const w = weatherFor(FROM);
|
||||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||
await inst.db.update(schema.account).set({ cash: 0 });
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
@@ -439,7 +440,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch
|
||||
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||
expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||
expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0);
|
||||
});
|
||||
|
||||
it('grounds as before when the queue is empty', async () => {
|
||||
@@ -497,78 +498,6 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(plane.destIcao).toBeNull();
|
||||
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 = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // 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 = T0 + travelMs(km, type.speedKmh); // 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)', () => {
|
||||
@@ -744,12 +673,12 @@ describe('grounded fleet freezes the world clock (#135)', () => {
|
||||
|
||||
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
|
||||
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
|
||||
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
|
||||
// Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
|
||||
// empty", not "taken" — a real load stamps the cooldown and would block this refill for REFILL_DELAY_MS.
|
||||
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen).
|
||||
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real
|
||||
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS.
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
if (p.status === 'open') {
|
||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +784,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.insert(schema.planes).values({
|
||||
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,
|
||||
id: 'p-2', 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);
|
||||
@@ -878,7 +807,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
|
||||
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
|
||||
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
|
||||
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
|
||||
|
||||
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
|
||||
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
|
||||
@@ -900,7 +829,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
|
||||
// never lands here.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'p-air', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
||||
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
||||
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
|
||||
});
|
||||
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
||||
|
||||
+21
-37
@@ -1,7 +1,6 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import { getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
@@ -9,7 +8,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 { SPECIALS } from '@/lib/game/specials';
|
||||
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
||||
import { broadcast, subscribedPlayerIds } from '@/lib/sse';
|
||||
import { broadcast } from '@/lib/sse';
|
||||
|
||||
import {
|
||||
JOB_LIFETIME_MS,
|
||||
@@ -112,7 +111,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await finishRepairs(inst, clockMs);
|
||||
}
|
||||
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
|
||||
// because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without
|
||||
// because while grounded nothing else creates packages but removal paths (ignore) still run; without
|
||||
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
|
||||
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
|
||||
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
|
||||
@@ -148,13 +147,9 @@ export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promi
|
||||
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
|
||||
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
|
||||
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
|
||||
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows
|
||||
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
|
||||
// lifetime, and no new ones can ever be created. Returns the count flipped.
|
||||
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). Returns the count flipped.
|
||||
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
|
||||
// Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
|
||||
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
|
||||
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
|
||||
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'));
|
||||
let n = 0;
|
||||
for (const p of rows) {
|
||||
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
|
||||
@@ -251,13 +246,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
for (const pk of onBoard) {
|
||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
||||
// v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
|
||||
// multiple deliveries in one tick all count.
|
||||
const fresh = await getPlayer(inst, p.ownerId);
|
||||
// Fresh account read per award so multiple deliveries in one tick all count.
|
||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||
if (!fresh) throw new Error('account row missing');
|
||||
// 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).
|
||||
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
||||
await updatePlayerEconomy(inst, p.ownerId, {
|
||||
await inst.db.update(schema.account).set({
|
||||
cash: fresh.cash + pk.reward,
|
||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
|
||||
});
|
||||
@@ -307,15 +302,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.
|
||||
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||
const liters = Math.max(0, target - landedFuel);
|
||||
// v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
|
||||
// delivery awards above) so a top-up in this tick sees current cash.
|
||||
const fresh = await getPlayer(inst, p.ownerId);
|
||||
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
|
||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||
if (!fresh) throw new Error('account row missing');
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost === 0 || fresh.cash >= cost) {
|
||||
queue.shift();
|
||||
chainedTo = nextDest;
|
||||
topUpFuel = target;
|
||||
if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
||||
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,7 +352,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
|
||||
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
|
||||
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
|
||||
// indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
|
||||
// indistinguishable. A TAKEN slot (loaded/ignored) stays empty for the band's refill delay after
|
||||
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
|
||||
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
|
||||
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
|
||||
@@ -384,16 +379,14 @@ export async function spawnPoolPackages(
|
||||
lon: f.properties.lon,
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
// v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
|
||||
// ponytail: 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 job quality; per-player job
|
||||
// quality is a later multiplayer step.
|
||||
const player = await getLocalPlayer(inst);
|
||||
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
|
||||
let n = 0;
|
||||
for (const originIcao of pool) {
|
||||
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
|
||||
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
|
||||
// Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter:
|
||||
// terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp.
|
||||
const openByBand = [0, 0, 0, 0];
|
||||
let blockedUntil: number | null = null;
|
||||
for (const p of packageRows) {
|
||||
@@ -418,7 +411,7 @@ export async function spawnPoolPackages(
|
||||
clockMs,
|
||||
id: randomUUID(),
|
||||
createdRealMs: real,
|
||||
reputation: player.reputation,
|
||||
reputation: account.reputation,
|
||||
}),
|
||||
);
|
||||
openByBand[bandIdx]++;
|
||||
@@ -436,22 +429,13 @@ export async function spawnPoolPackages(
|
||||
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
||||
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||
// other clients too.
|
||||
// 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> {
|
||||
const frames = new Map<string, string>();
|
||||
for (const playerId of subscribedPlayerIds()) {
|
||||
try {
|
||||
frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId))));
|
||||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
||||
} catch (err) {
|
||||
console.error('flight: state broadcast failed', err);
|
||||
}
|
||||
}
|
||||
if (frames.size > 0) broadcast(frames);
|
||||
}
|
||||
|
||||
export function tickNow(): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => {
|
||||
|
||||
+18
-38
@@ -1,65 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { broadcast, subscribedPlayerIds, subscribe } from './sse';
|
||||
import { broadcast, subscribe } from './sse';
|
||||
|
||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
|
||||
// leak across tests in this file (module-level state), which is fine: each test asserts on its own
|
||||
// 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.
|
||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
|
||||
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
||||
// each test only asserts on its own arrays.
|
||||
|
||||
describe('lib/sse', () => {
|
||||
it('delivers broadcasts to subscribers of the SAME player only, and stops after unsubscribe', () => {
|
||||
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
|
||||
const a: string[] = [];
|
||||
const b: string[] = []; // same player as a — both get its frames
|
||||
const c: string[] = []; // different player — never sees their frames
|
||||
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));
|
||||
const b: string[] = [];
|
||||
const unsubA = subscribe((t) => a.push(t));
|
||||
const unsubB = subscribe((t) => b.push(t));
|
||||
|
||||
broadcast(new Map([['p-a', 'x']]));
|
||||
broadcast('x');
|
||||
expect(a).toEqual(['x']);
|
||||
expect(b).toEqual(['x']); // same group
|
||||
expect(c).toEqual([]); // other player's frame never crosses over
|
||||
expect(b).toEqual(['x']);
|
||||
|
||||
unsubA();
|
||||
broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
|
||||
broadcast('y');
|
||||
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||
expect(b).toEqual(['x', 'y']);
|
||||
expect(c).toEqual(['z']);
|
||||
unsubB();
|
||||
unsubC();
|
||||
});
|
||||
|
||||
it('subscribedPlayerIds lists exactly the players with live streams, dropping empty groups', () => {
|
||||
it('a throwing subscriber does not break the others', () => {
|
||||
const got: string[] = [];
|
||||
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', () => {
|
||||
subscribe(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const unsub = subscribe('p-boom', (t) => got.push(t));
|
||||
broadcast(new Map([['p-boom', 'z']]));
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
broadcast('z');
|
||||
expect(got).toEqual(['z']);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
|
||||
it('unsubscribe is idempotent', () => {
|
||||
const got: string[] = [];
|
||||
const unsub = subscribe('p-idem', (t) => got.push(t));
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
unsub();
|
||||
unsub(); // second call must not throw
|
||||
broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
|
||||
broadcast('w');
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-31
@@ -1,45 +1,25 @@
|
||||
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
||||
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
|
||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
|
||||
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
||||
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
||||
// give each its own empty 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.
|
||||
// give each its own empty set and no push would ever be delivered.
|
||||
|
||||
type Push = (text: string) => void;
|
||||
|
||||
const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
|
||||
const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
|
||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
||||
|
||||
export function subscribe(playerId: string, fn: Push): () => void {
|
||||
let group = subscribers.get(playerId);
|
||||
if (!group) {
|
||||
group = new Set();
|
||||
subscribers.set(playerId, group);
|
||||
}
|
||||
group.add(fn);
|
||||
export function subscribe(fn: Push): () => void {
|
||||
subscribers.add(fn);
|
||||
return () => {
|
||||
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
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
|
||||
// projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
|
||||
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]) {
|
||||
// Call each subscriber safely — one dead stream must not take the others down.
|
||||
export function broadcast(text: string): void {
|
||||
for (const fn of [...subscribers]) {
|
||||
try {
|
||||
fn(text);
|
||||
} catch {
|
||||
@@ -47,4 +27,3 @@ export function broadcast(frames: Map<string, string>): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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
|
||||
// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
|
||||
@@ -31,8 +30,7 @@ export function useGameAction(
|
||||
try {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
// v1.1.0 (ISS-107): action endpoints are session-guarded — echo the stored id.
|
||||
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
@@ -22,19 +22,7 @@ describe('fetchState', () => {
|
||||
|
||||
await expect(fetchState()).resolves.toEqual(SNAPSHOT);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// 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' } });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' });
|
||||
});
|
||||
|
||||
it('throws with the HTTP status on a non-2xx response', async () => {
|
||||
|
||||
+2
-7
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { sessionHeaders, storedSession } from './authClient';
|
||||
|
||||
export interface GameStateResult {
|
||||
state: StateSnapshot | null;
|
||||
@@ -20,8 +19,7 @@ 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.
|
||||
export async function fetchState(): Promise<StateSnapshot> {
|
||||
// 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() });
|
||||
const res = await fetch('/api/state', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
|
||||
const snapshot = (await res.json()) as StateSnapshot;
|
||||
// Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what
|
||||
@@ -95,10 +93,7 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
||||
|
||||
let source: EventSource | null = null;
|
||||
try {
|
||||
// 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);
|
||||
source = new EventSource(SSE_URL);
|
||||
} catch {
|
||||
source = null; // no EventSource support — pure polling below
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.4.0",
|
||||
"version": "0.70.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user