Compare commits

...
16 Commits
Author SHA1 Message Date
kamilkrzywda db77f03ce1 Merge pull request 'v1.3.0: per-player money & reputation in engine + actions (ISS-110)' (#154) from feat/multiplayer-per-player-economy into master
CI / build + unit + e2e (push) Successful in 13m45s
2026-09-26 19:50:20 +02:00
kamilkrzywda d00a012c4b v1.3.0: per-player money & reputation in engine + actions (ISS-110)
CI / build + unit + e2e (pull_request) Successful in 13m20s
2026-09-26 17:03:15 +00:00
kamilkrzywda 306f81aec6 Merge pull request 'v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)' (#153) from feat/multiplayer-ownership into master
CI / build + unit + e2e (push) Successful in 13m22s
2026-09-26 18:41:06 +02:00
kamilkrzywda ec26f9f366 v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s
2026-09-26 16:27:00 +00:00
kamilkrzywda ba2c36a3f8 Merge pull request 'v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)' (#152) from feat/multiplayer-auth-guard into master
CI / build + unit + e2e (push) Successful in 14m39s
2026-09-26 17:43:19 +02:00
kamilkrzywda 81ba642ec5 fix: add missing sign-in input testids (ISS-107) CI
CI / build + unit + e2e (pull_request) Successful in 14m20s
2026-09-26 15:28:54 +00:00
kamilkrzywda b0f1bd8b67 v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s
2026-09-26 14:40:56 +00:00
kamilkrzywda 21dd37f88c Merge pull request 'v1.0.0: players table + migration from singleton account (ISS-105)' (#151) from feat/multiplayer-players-table into master
CI / build + unit + e2e (push) Successful in 13m33s
v1.0.0: players table + migration from singleton account (ISS-105)
2026-09-24 16:41:52 +02:00
kamilkrzywda b2034ebc52 v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s
2026-09-24 14:26:34 +00:00
kamilkrzywda 837aa60a7d Merge pull request 'v0.73.0: remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
CI / build + unit + e2e (push) Successful in 13m46s
Merge pull request 'v0.73.0 — remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
2026-09-24 14:39:29 +02:00
kamilkrzywda 7b6a942e0c v0.73.0: remove new-package toast notifications
CI / build + unit + e2e (pull_request) Successful in 13m31s
2026-09-24 12:25:27 +00:00
kamilkrzywda 1a1834abc7 Merge pull request 'v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call' (#149) from fix/admin-login-hash into master
CI / build + unit + e2e (push) Successful in 13m14s
v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
2026-09-24 13:59:28 +02:00
kamilkrzywda b5bb0d01ce v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
CI / build + unit + e2e (pull_request) Successful in 14m7s
2026-09-24 11:45:06 +00:00
kamilkrzywda fe23c9ff0f Merge pull request 'v0.71.0 Admin panel — users table, login/sessions, generic DB CRUD modal' (#148) from feat/admin-panel into master
CI / build + unit + e2e (push) Successful in 13m21s
2026-09-24 12:11:46 +02:00
kamilkrzywda e24a5788c0 v0.71.0: fix admin e2e — pass sessionId into page.evaluate
CI / build + unit + e2e (pull_request) Successful in 15m37s
2026-09-24 09:56:05 +00:00
kamilkrzywda b89d351587 v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
2026-09-24 09:24:31 +00:00
82 changed files with 3864 additions and 332 deletions
+40
View File
@@ -1,5 +1,45 @@
# Changelog # Changelog
## 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 ## 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. - **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). - **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).
@@ -0,0 +1,184 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { DELETE, GET, POST, PUT } from './route';
// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables),
// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input).
let dir: string;
let adminSession = '';
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
// adminSession is minted in beforeAll, so the header helpers read it at call time.
const authHeaders = () => ({ 'X-Flight-Session': adminSession });
// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly.
const ctx = (name: string) => ({ params: Promise.resolve({ name }) });
const getRows = (name: string, query = '') =>
GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name));
const putRow = (name: string, body: unknown) =>
PUT(
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
method: 'PUT',
headers: { ...authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
ctx(name),
);
const postRow = (name: string, body: unknown) =>
POST(
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
method: 'POST',
headers: { ...authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
ctx(name),
);
const deleteRow = (name: string, rowid: number | null) =>
DELETE(
new Request(
`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`,
{ method: 'DELETE', headers: authHeaders() },
),
ctx(name),
);
describe('admin rows CRUD', () => {
beforeAll(async () => {
adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
it('GET returns columns + rows with __rowid for the PK-less sim_state table', async () => {
const res = await getRows('sim_state');
expect(res.status).toBe(200);
const body = (await res.json()) as { columns: string[]; rows: Array<Record<string, unknown>> };
expect(body.columns[0]).toBe('__rowid');
for (const col of ['clock_ms', 'time_scale']) expect(body.columns).toContain(col);
expect(body.rows).toHaveLength(1); // the single seeded sim_state row
expect(typeof body.rows[0].__rowid).toBe('number');
expect(typeof body.rows[0].clock_ms).toBe('number');
});
it('PUT updates a cell and the change survives a re-fetch', async () => {
const before = await (await getRows('sim_state')).json();
const originalClockMs = (before as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms;
const res = await putRow('sim_state', { rowid: 1, values: { clock_ms: 424_242 } });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
// Re-fetch through the same route — the persisted value is back.
const body = await (await getRows('sim_state')).json();
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].clock_ms).toBe(424_242);
// Restore the original clock so later tests see a sane world.
const restore = await putRow('sim_state', { rowid: 1, values: { clock_ms: originalClockMs } });
expect(restore.status).toBe(200);
});
it('PUT rejects unknown columns and empty values with 400', async () => {
const badCol = await putRow('sim_state', { rowid: 1, values: { no_such_column: 1 } });
expect(badCol.status).toBe(400);
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
const empty = await putRow('sim_state', { rowid: 1, values: {} });
expect(empty.status).toBe(400);
const noRowid = await putRow('sim_state', { values: { clock_ms: 1 } });
expect(noRowid.status).toBe(400);
});
it('POST inserts a row and DELETE removes it again (users table)', async () => {
const created = await postRow('users', {
values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() },
});
expect(created.status).toBe(200);
const body = (await created.json()) as { ok: boolean; rowid: number };
expect(body.ok).toBe(true);
expect(Number.isInteger(body.rowid)).toBe(true);
// Visible via GET…
let list = await (await getRows('users')).json();
const rows = (list as { rows: Array<Record<string, unknown>> }).rows;
expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true);
// …and gone after DELETE.
const del = await deleteRow('users', body.rowid);
expect(del.status).toBe(200);
list = await (await getRows('users')).json();
expect((list as { rows: Array<Record<string, unknown>> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false);
// DELETE is idempotent.
const again = await deleteRow('users', body.rowid);
expect(again.status).toBe(200);
});
it('POST rejects unknown columns and empty values with 400', async () => {
const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } });
expect(badCol.status).toBe(400);
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
const empty = await postRow('users', { values: {} });
expect(empty.status).toBe(400);
});
it('DELETE requires an integer rowid query parameter', async () => {
const missing = await deleteRow('users', null);
expect(missing.status).toBe(400);
const bad = await DELETE(
new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }),
ctx('users'),
);
expect(bad.status).toBe(400);
});
it('returns 404 for an unknown table on every verb', async () => {
const g = await getRows('no_such_table');
expect(g.status).toBe(404);
const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } });
expect(p.status).toBe(404);
const i = await postRow('no_such_table', { values: { x: 1 } });
expect(i.status).toBe(404);
const d = await deleteRow('no_such_table', 1);
expect(d.status).toBe(404);
});
it('rejects a non-integer limit with 400 and caps at 1000', async () => {
const bad = await getRows('users', '?limit=abc');
expect(bad.status).toBe(400);
const capped = await getRows('users', '?limit=999999');
expect(capped.status).toBe(200);
});
it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => {
const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users'));
expect(noHeader.status).toBe(401);
const inst = await getGameDb();
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`,
[Date.now()],
);
const nonAdminSession = await createSession(inst, 'user-rows-nonadmin');
const forbidden = await GET(
new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }),
ctx('users'),
);
expect(forbidden.status).toBe(403);
});
});
+219
View File
@@ -0,0 +1,219 @@
import { persist } from '@/lib/db/client';
import type { GameDb } from '@/lib/db/client';
import { requireAdmin } from '@/lib/auth';
// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any
// user input is touched). SQL safety: table names are quoted only after validation against
// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE
// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string.
const DEFAULT_LIMIT = 500;
const MAX_LIMIT = 1000;
function quoteIdent(name: string): string {
return '"' + name.replace(/"/g, '""') + '"';
}
// The app's own tables (sqlite_% internals excluded) — the only names ever accepted.
function tableNames(inst: GameDb): Set<string> {
const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
return new Set(res[0].values.map((r) => String(r[0])));
}
// Column names of a validated table (name already checked against sqlite_master).
function columnsOf(inst: GameDb, name: string): string[] {
const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`);
return res[0].values.map((r) => String(r[1]));
}
// Parameterized execute — the only way values reach SQL in this file.
function run(inst: GameDb, sql: string, values: unknown[]): void {
inst.raw.run(sql, values as never[]);
}
async function readBody(req: Request): Promise<Record<string, unknown> | Response> {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!ALLOWED_BODY_FIELDS.has(key)) {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
return input;
}
// Per-method top-level body fields (strict, like the /api/test/* hooks).
const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']);
function valuesObject(input: Record<string, unknown>): Record<string, unknown> | Response {
const values = input.values;
if (typeof values !== 'object' || values === null || Array.isArray(values)) {
return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 });
}
return values as Record<string, unknown>;
}
// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit;
// anything else is a server error.
function sqlError(err: unknown): Response {
const msg = err instanceof Error ? err.message : String(err);
if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) {
return Response.json({ error: `database constraint: ${msg}` }, { status: 409 });
}
console.error('admin rows route failed', err);
return Response.json({ error: 'database error' }, { status: 500 });
}
// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default
// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable
// like any other. Unknown table → 404; bad limit → 400.
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
let limit = DEFAULT_LIMIT;
const limitParam = new URL(req.url).searchParams.get('limit');
if (limitParam !== null) {
limit = Number(limitParam);
if (!Number.isInteger(limit) || limit < 1) {
return Response.json({ error: 'limit must be a positive integer' }, { status: 400 });
}
limit = Math.min(limit, MAX_LIMIT);
}
const cols = columnsOf(guard.inst, name);
const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`);
try {
stmt.bind([limit]);
const rows: Array<Record<string, unknown>> = [];
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
return Response.json({ columns: ['__rowid', ...cols], rows });
} catch (err) {
return sqlError(err);
} finally {
stmt.free();
}
}
// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the
// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty
// values → 400.
export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const input = await readBody(req);
if (input instanceof Response) return input;
const rowid = input.rowid;
if (!Number.isInteger(rowid)) {
return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 });
}
const values = await valuesObject(input);
if (values instanceof Response) return values;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const cols = columnsOf(guard.inst, name);
for (const col of Object.keys(values)) {
if (!cols.includes(col)) {
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
}
}
if (Object.keys(values).length === 0) {
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
}
const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', ');
try {
run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [
...Object.values(values),
rowid,
]);
await persist(guard.inst);
return Response.json({ ok: true });
} catch (err) {
return sqlError(err);
}
}
// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with
// exactly the given columns (others take their defaults or NULL). Returns the new rowid.
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const input = await readBody(req);
if (input instanceof Response) return input;
const values = await valuesObject(input);
if (values instanceof Response) return values;
if (Object.keys(values).length === 0) {
return Response.json({ error: 'values must contain at least one column' }, { status: 400 });
}
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const cols = columnsOf(guard.inst, name);
for (const col of Object.keys(values)) {
if (!cols.includes(col)) {
return Response.json({ error: `unknown column "${col}"` }, { status: 400 });
}
}
const keys = Object.keys(values);
try {
run(
guard.inst,
`INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
Object.values(values),
);
// Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets
// last_insert_rowid to 0 on this connection.
const res = guard.inst.raw.exec('SELECT last_insert_rowid()');
await persist(guard.inst);
return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) });
} catch (err) {
return sqlError(err);
}
}
// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404;
// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent).
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const { name } = await ctx.params;
if (!tableNames(guard.inst).has(name)) {
return Response.json({ error: 'unknown table' }, { status: 404 });
}
const rowidParam = new URL(req.url).searchParams.get('rowid');
const rowid = Number(rowidParam);
if (rowidParam === null || !Number.isInteger(rowid)) {
return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 });
}
try {
run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]);
await persist(guard.inst);
return Response.json({ ok: true });
} catch (err) {
return sqlError(err);
}
}
+80
View File
@@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { GET } from './route';
// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403,
// admin passes and sees every user table (sqlite_% excluded).
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const get = (sessionId?: string) =>
GET(
new Request('http://localhost/api/admin/tables', {
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
async function makeUser(username: string, admin: number): Promise<string> {
const inst = await getGameDb();
const id = `user-${username}`;
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`,
[id, username, admin, Date.now(), id],
);
return id;
}
describe('GET /api/admin/tables', () => {
it('returns 401 without a session header', async () => {
const res = await get();
expect(res.status).toBe(401);
});
it('returns 401 for an expired session', async () => {
const inst = await getGameDb();
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
'expired-admin-session',
'admin-kamilkrzywda',
Date.now() - 1000,
]);
const res = await get('expired-admin-session');
expect(res.status).toBe(401);
});
it('returns 403 for a valid non-admin session', async () => {
const userId = await makeUser('tables-nonadmin', 0);
const sessionId = await createSession(await getGameDb(), userId);
const res = await get(sessionId);
expect(res.status).toBe(403);
expect(((await res.json()) as { error: string }).error).toMatch(/admin/i);
});
it('lists every user table for an admin, excluding sqlite_% internals', async () => {
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
const res = await get(sessionId);
expect(res.status).toBe(200);
const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name);
for (const expected of ['packages', 'players', 'planes', 'sessions', 'sim_state', 'users']) {
expect(names, `missing ${expected}`).toContain(expected);
}
expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false);
// Name-ordered.
expect([...names].sort()).toEqual(names);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { requireAdmin } from '@/lib/auth';
// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name
// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker.
export async function GET(req: Request) {
const guard = await requireAdmin(req);
if (!guard.ok) return guard.response;
const res = guard.inst.raw.exec(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
);
return Response.json(res[0].values.map((r) => ({ name: String(r[0]) })));
}
+16 -1
View File
@@ -2,9 +2,12 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createSession } from '@/lib/auth';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route'; import { GET } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so // Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
@@ -12,6 +15,8 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton). // vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -20,8 +25,18 @@ afterAll(async () => {
}); });
describe('GET /api/events', () => { describe('GET /api/events', () => {
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
// param — this test exercises exactly that path (requireAuthSse).
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
it('rejects an unauthenticated request with 401', async () => {
const res = await GET(new Request('http://localhost/api/events'));
expect(res.status).toBe(401);
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
});
it('streams SSE with the current snapshot as the first event', async () => { it('streams SSE with the current snapshot as the first event', async () => {
const res = await GET(); const res = await GET(req());
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache'); expect(res.headers.get('cache-control')).toBe('no-cache');
+14 -4
View File
@@ -1,3 +1,4 @@
import { requireAuthSse } from '@/lib/auth';
import { getStateSnapshot, sseFrame } from '@/lib/game/state'; import { getStateSnapshot, sseFrame } from '@/lib/game/state';
import { subscribe } from '@/lib/sse'; import { subscribe } from '@/lib/sse';
@@ -5,12 +6,19 @@ import { subscribe } from '@/lib/sse';
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions // then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0): // stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the // frames carry the sseFrame projection (terminal package history dropped) — same shape as the
// pushed frames, so a connect never costs one full-history frame. // pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
// be one indexed lookup per tick if that ever matters).
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000; const HEARTBEAT_MS = 15_000;
export async function GET() { export async function GET(req: Request) {
const auth = await requireAuthSse(req);
if (!auth.ok) return auth.response;
let heartbeat: ReturnType<typeof setInterval> | null = null; let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
const cleanup = () => { const cleanup = () => {
@@ -22,8 +30,10 @@ export async function GET() {
start(controller) { start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so // Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s // events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
// for the next push instead of seeing an out-of-order frame. // for the next push instead of seeing an out-of-order frame. v1.3.0 (ISS-110): the connect
getStateSnapshot() // frame carries THIS session's account row; later pushes come from the shared broadcast
// (local player) until per-player frames land (#112).
getStateSnapshot(auth.player.id)
.then((snapshot) => { .then((snapshot) => {
unsubscribe = subscribe((text) => { unsubscribe = subscribe((text) => {
try { try {
+93
View File
@@ -0,0 +1,93 @@
import { randomUUID, scryptSync } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { SESSION_TTL_MS } from '@/lib/auth';
import { POST } from './route';
// v0.71.0 — login route tests. The test user is created HERE with a hash computed in-test via
// scryptSync; the seeded admin's plaintext password must never appear in any test code, so it
// is deliberately not exercised here (the e2e suite gets an admin session via /api/test/admin-session).
let dir: string;
const TEST_USER = 'unit-test-user';
const TEST_PASSWORD = 'right-horse-wrong-street-42';
const SALT_HEX = randomUUID().replace(/-/g, '').slice(0, 32); // 16-byte hex salt
const HASH = `${SALT_HEX}$${scryptSync(TEST_PASSWORD, SALT_HEX, 32).toString('hex')}`;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-login-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'login.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/login', { method: 'POST', body: JSON.stringify(body) }));
async function createTestUser(): Promise<void> {
const inst = await getGameDb();
// Idempotent — two tests in this file both want the user.
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-unit-test', ?, ?, 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = ?)`,
[TEST_USER, HASH, Date.now(), TEST_USER],
);
}
describe('POST /api/login', () => {
it('rejects malformed bodies with 400', async () => {
for (const body of [{}, { username: 'x' }, { password: 'y' }, { username: '', password: 'p' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('returns 401 for an unknown username (and still runs a scrypt)', async () => {
const res = await post({ username: 'no-such-user', password: TEST_PASSWORD });
expect(res.status).toBe(401);
expect(((await res.json()) as { error: string }).error).toMatch(/invalid credentials/i);
});
it('returns 401 for a wrong password and mints no session', async () => {
await createTestUser();
const before = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0];
const res = await post({ username: TEST_USER, password: 'definitely-wrong' });
expect(res.status).toBe(401);
const after = Number((await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]);
expect(after).toBe(Number(before));
});
it('mints a ~7-day session on success and returns the user', async () => {
await createTestUser();
const t0 = Date.now();
const res = await post({ username: TEST_USER, password: TEST_PASSWORD });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
expect(body.user).toEqual({ username: TEST_USER, admin: false });
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const row = (await getGameDb()).raw.exec('SELECT user_id, expires_at FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(row[0])).toBe('user-unit-test');
const ttl = Number(row[1]) - t0;
expect(ttl).toBeGreaterThan(SESSION_TTL_MS - 5_000); // ~7 days, minus test drift
expect(ttl).toBeLessThan(SESSION_TTL_MS + 5_000);
});
it('login works for the seeded admin row too (hash verified without its plaintext)', async () => {
// The seeded admin's hash is a fixed string in client.ts; we can only prove the route
// ACCEPTS exactly that stored digest by checking verification against a known pair — which
// would require the plaintext. So instead: assert the seeded row is what login looks up, by
// confirming an unknown-password attempt for 'kamilkrzywda' yields 401 (row found, hash mismatch).
const res = await post({ username: 'kamilkrzywda', password: 'wrong-on-purpose' });
expect(res.status).toBe(401);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { getGameDb } from '@/lib/db/client';
import { createSession, verifyPassword, DUMMY_HASH_FOR_UNKNOWN_USER } from '@/lib/auth';
// v0.71.0 — POST /api/login
// body { username: string, password: string } (both required non-empty; unknown fields 400).
// Success → 200 { sessionId, user: { username, admin } }; the session row lives 7 days and is
// sent back as X-Flight-Session on later requests. Failure → 401 { error }. An unknown
// username still runs one full scrypt against a fixed dummy digest so timing stays uniform.
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username' && key !== 'password') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = input.username;
const password = input.password;
if (typeof username !== 'string' || username.length === 0) {
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
}
if (typeof password !== 'string' || password.length === 0) {
return Response.json({ error: 'password is required and must be a non-empty string' }, { status: 400 });
}
const inst = await getGameDb();
const res = inst.raw.exec('SELECT id, password_hash, admin FROM users WHERE username = ?', [username]);
const row = res[0]?.values?.[0];
if (!row) {
verifyPassword(password, DUMMY_HASH_FOR_UNKNOWN_USER); // uniform timing — result discarded
return Response.json({ error: 'invalid credentials' }, { status: 401 });
}
if (!verifyPassword(password, String(row[1]))) {
return Response.json({ error: 'invalid credentials' }, { status: 401 });
}
const sessionId = await createSession(inst, String(row[0]));
return Response.json({
sessionId,
user: { username, admin: Number(row[2]) === 1 },
});
}
+60
View File
@@ -0,0 +1,60 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
// v0.71.0 — logout route tests: deletes the session named by X-Flight-Session, idempotently.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-logout-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'logout.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (sessionId?: string) =>
POST(
new Request('http://localhost/api/logout', {
method: 'POST',
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
async function mintSession(): Promise<string> {
const inst = await getGameDb();
return createSession(inst, 'admin-kamilkrzywda'); // any user id works — logout only deletes the session row
}
describe('POST /api/logout', () => {
it('returns 401 without a session header', async () => {
const res = await post();
expect(res.status).toBe(401);
});
it('deletes the named session and is idempotent on repeat calls', async () => {
const sessionId = await mintSession();
const count = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
expect(Number(count)).toBe(1);
const first = await post(sessionId);
expect(first.status).toBe(200);
expect(await first.json()).toEqual({ ok: true });
// The row is gone…
const after = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0];
expect(Number(after)).toBe(0);
// …and a second logout of the same (now unknown) session is still 200.
const second = await post(sessionId);
expect(second.status).toBe(200);
expect(await second.json()).toEqual({ ok: true });
});
});
+16
View File
@@ -0,0 +1,16 @@
import { getGameDb, persist } from '@/lib/db/client';
import { SESSION_HEADER } from '@/lib/auth';
// v0.71.0 — POST /api/logout (no body). Deletes the session named by the X-Flight-Session
// header; idempotent — deleting an unknown or already-deleted session is still 200. A missing
// header has nothing to delete and gets 401 like every other unauthenticated request here.
export async function POST(req: Request) {
const sessionId = req.headers.get(SESSION_HEADER);
if (!sessionId) return Response.json({ error: 'authentication required' }, { status: 401 });
const inst = await getGameDb();
inst.raw.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
await persist(inst);
return Response.json({ ok: true });
}
+17 -7
View File
@@ -2,18 +2,22 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -21,7 +25,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) })); const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/buy-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/buy-plane', () => { describe('POST /api/market/buy-plane', () => {
it('rejects a missing model, unknown fields and unknown models with 400', async () => { it('rejects a missing model, unknown fields and unknown models with 400', async () => {
@@ -38,14 +49,13 @@ describe('POST /api/market/buy-plane', () => {
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
// Cash untouched by the failed attempt. // Cash untouched by the failed attempt.
const account = (await (await getGameDb()).db.select().from(schema.account))[0]; expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000);
expect(account.cash).toBe(20_000);
}); });
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => { it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
const { db } = await getGameDb(); const inst = await getGameDb();
// Single-row account table — top up cash so the purchase can succeed. // Local player row — top up cash so the purchase can succeed.
await db.update(schema.account).set({ cash: 500_000 }); await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const res = await post({ model: 'atr72' }); const res = await post({ model: 'atr72' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
} }
const model = input.model; const model = input.model;
return performAction((inst) => buyPlane(inst, model)); return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
} }
+17 -5
View File
@@ -4,17 +4,22 @@ import path from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,7 +27,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) })); const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/sell-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/sell-plane', () => { describe('POST /api/market/sell-plane', () => {
it('rejects a missing planeId and unknown fields with 400', async () => { it('rejects a missing planeId and unknown fields with 400', async () => {
@@ -48,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
// A second, grounded plane so "flying" is the reason for rejection. // A second, grounded plane so "flying" is the reason for rejection.
await db.insert(schema.planes).values({ await db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
}); });
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra')); await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
@@ -59,10 +71,10 @@ describe('POST /api/market/sell-plane', () => {
}); });
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => { it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
const { db } = await getGameDb(); const inst = await getGameDb();
// The previous test left 'plane-extra' in the DB (now flying) — ground it. // The previous test left 'plane-extra' in the DB (now flying) — ground it.
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra')); await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
const cashBefore = (await db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post({ planeId: 'plane-extra' }); const res = await post({ planeId: 'plane-extra' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
} }
const planeId = input.planeId; const planeId = input.planeId;
return performAction((inst) => sellPlane(inst, planeId)); return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
} }
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { GET } from './route';
// v0.71.0 — /api/me tests: resolves X-Flight-Session to its user, 401 otherwise.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-me-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'me.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const get = (sessionId?: string) =>
GET(
new Request('http://localhost/api/me', {
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
}),
);
describe('GET /api/me', () => {
it('returns 401 without a session header', async () => {
const res = await get();
expect(res.status).toBe(401);
});
it('returns 401 for an unknown session id', async () => {
const res = await get('00000000-0000-4000-8000-000000000000');
expect(res.status).toBe(401);
});
it('returns 401 for an expired session', async () => {
const inst = await getGameDb();
// A session whose expiry is already in the past.
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
'expired-session-id',
'admin-kamilkrzywda',
Date.now() - 1000,
]);
const res = await get('expired-session-id');
expect(res.status).toBe(401);
});
it('returns the user for a live session (admin flag included)', async () => {
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ username: 'kamilkrzywda', admin: true });
});
it('reports non-admin users as such', async () => {
const inst = await getGameDb();
inst.raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'user-me-test', 'me-test-user', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-me-test')`,
[Date.now()],
);
const sessionId = await createSession(inst, 'user-me-test');
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ username: 'me-test-user', admin: false });
});
});
+11
View File
@@ -0,0 +1,11 @@
import { currentUser } from '@/lib/auth';
// v0.71.0 — GET /api/me (no body). Returns the user behind a valid X-Flight-Session header as
// { username, admin }, or 401 when the header is missing/unknown/expired. The client uses it to
// decide what the Admin modal shows on open.
export async function GET(req: Request) {
const user = await currentUser(req);
if (!user) return Response.json({ error: 'authentication required' }, { status: 401 });
return Response.json({ username: user.username, admin: user.admin });
}
+13 -5
View File
@@ -4,16 +4,21 @@ import path from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -21,9 +26,11 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
}); });
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), { const post = (id: string) =>
params: Promise.resolve({ id }), POST(
}); new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
{ params: Promise.resolve({ id }) },
);
// Insert a LOADED package bound to the starter plane with a long deadline. Returns // Insert a LOADED package bound to the starter plane with a long deadline. Returns
// the exact created/deadline times so tests can compute the penalty from the // the exact created/deadline times so tests can compute the penalty from the
@@ -84,8 +91,9 @@ describe('POST /api/packages/:id/abandon', () => {
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => { it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
const { db } = await getGameDb(); const inst = await getGameDb();
const cashBefore = (await db.select().from(schema.account))[0].cash; const { db } = inst;
const cashBefore = (await getLocalPlayer(inst)).cash;
const res = await post('p-ab'); const res = await post('p-ab');
expect(res.status).toBe(200); expect(res.status).toBe(200);
+2 -2
View File
@@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
// id; 409 wrong status or the plane is in flight. On success returns the full // id; 409 wrong status or the plane is in flight. On success returns the full
// state snapshot. // state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params; const { id } = await ctx.params;
return performAction((inst) => abandonPackage(inst, id)); return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
} }
+12 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
}); });
const post = (id: string, body: unknown) => const post = (id: string, body: unknown) =>
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), { POST(
params: Promise.resolve({ id }), new Request(`http://localhost/api/packages/${id}/load`, {
}); method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id }) },
);
describe('POST /api/packages/:id/load', () => { describe('POST /api/packages/:id/load', () => {
it('rejects a missing/non-string planeId and unknown fields with 400', async () => { it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
} }
const planeId = input.planeId; const planeId = input.planeId;
return performAction((inst) => loadPackage(inst, id, planeId)); return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
} }
+12 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a // Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton). // throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
}); });
const post = (planeId: string, body: unknown) => const post = (planeId: string, body: unknown) =>
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), { POST(
params: Promise.resolve({ planeId }), new Request(`http://localhost/api/planes/${planeId}/fly`, {
}); method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ planeId }) },
);
describe('POST /api/planes/:planeId/fly', () => { describe('POST /api/planes/:planeId/fly', () => {
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => { it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const destIcao = input.destIcao; const destIcao = input.destIcao;
return performAction((inst) => flyPlane(inst, planeId, destIcao)); return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
} }
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const index = input.index as number; const index = input.index as number;
return performAction((inst) => cancelQueuedLeg(inst, planeId, index)); return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
} }
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
} }
const destIcao = input.destIcao; const destIcao = input.destIcao;
return performAction((inst) => queueLeg(inst, planeId, destIcao)); return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
} }
+2 -2
View File
@@ -6,7 +6,7 @@ import { performAction, repairPlane } from '@/lib/game/actions';
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. // repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
// On success returns the full state snapshot. // On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params; const { planeId } = await ctx.params;
return performAction((inst) => repairPlane(inst, planeId)); return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
} }
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { verifyPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { POST } from './route';
// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB
// singleton points at a throwaway file, and every created user is verified through verifyPassword().
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'register.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/register', { method: 'POST', body: JSON.stringify(body) }));
describe('POST /api/register', () => {
it('rejects malformed bodies and bad credentials with 400', async () => {
for (const body of [
{}, // missing both fields
{ username: 'ab', password: 'longenough' }, // too short
{ username: 'a'.repeat(21), password: 'longenough' }, // too long
{ username: 'bad user!', password: 'longenough' }, // bad charset (space + !)
{ username: 'validuser', password: 'short' }, // password < 8 chars
{ username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field
]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => {
const res = await post({ username: 'NewUser42', password: 'correct-horse-1' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0];
const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true);
expect(String(playerRow[1])).toBe('local');
expect(Number(playerRow[2])).toBe(STARTING_CASH);
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('returns 409 when the username is taken (case-insensitively)', async () => {
await post({ username: 'taken-user', password: 'correct-horse-1' });
const res = await post({ username: 'Taken-User', password: 'different-pass-2' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// v1.1.0 (ISS-107) — POST /api/register
// body { username: string, password: string } (both required; unknown fields 400).
// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400.
// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same
// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact
// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a
// session immediately and returns { sessionId, user } like /api/login — no second round-trip.
const USERNAME_RE = /^[a-z0-9_-]{3,20}$/;
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username' && key !== 'password') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = typeof input.username === 'string' ? input.username.toLowerCase() : '';
if (!USERNAME_RE.test(username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
const password = input.password;
if (typeof password !== 'string' || password.length < 8) {
return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 });
}
const inst = await getGameDb();
const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]);
if (taken[0]?.values?.length) {
return Response.json({ error: 'username is already taken' }, { status: 409 });
}
// One UUID for both rows — the account and its player share an id; requireAuth still joins on username.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(password),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
await persist(inst);
const sessionId = await createSession(inst, id);
return Response.json({ sessionId, user: { username, admin: false } });
}
+33 -4
View File
@@ -5,9 +5,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route'; import { GET } from './route';
let dir: string; let dir: string;
let sessionId: string;
beforeAll(async () => { beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never // Point the app's DB singleton at a throwaway file so this test never
@@ -15,8 +19,14 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton). // vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-')); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db'); process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
// (no password needed: createSession only inserts into sessions; the guard joins players on
// username → the 'local' row).
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
}); });
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
afterAll(async () => { afterAll(async () => {
delete process.env.FLIGHT_DB_PATH; delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
@@ -24,7 +34,7 @@ afterAll(async () => {
describe('GET /api/state', () => { describe('GET /api/state', () => {
it('returns 200 with the full snapshot shape', async () => { it('returns 200 with the full snapshot shape', async () => {
const res = await GET(); const res = await GET(req());
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json'); expect(res.headers.get('content-type')).toContain('application/json');
@@ -75,13 +85,13 @@ describe('GET /api/state', () => {
}); });
it('is stable across repeated calls (same seeded DB)', async () => { it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>; const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>; const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash); expect((await b).account.cash).toBe((await a).account.cash);
}); });
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => { it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{ const body = ((await GET(req())).json()) as Promise<{
catalog: Array<Record<string, unknown>>; catalog: Array<Record<string, unknown>>;
fuelPricePerL: number; fuelPricePerL: number;
sellFraction: number; sellFraction: number;
@@ -104,4 +114,23 @@ describe('GET /api/state', () => {
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L); expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION); expect(snap.sellFraction).toBe(SELL_FRACTION);
}); });
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy — rows minted like /api/register does.
const id = 'test-player-b';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
});
const sid = await createSession(inst, id);
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
expect(res.status).toBe(200);
const body = (await res.json()) as { account: { cash: number; reputation: number } };
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
});
}); });
+8 -2
View File
@@ -1,12 +1,18 @@
import { requireAuth } from '@/lib/auth';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
export async function GET() { // v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
// unchanged; per-player snapshots are a later step (#112).
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
export async function GET(req: Request) {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try { try {
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every // Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
// read reflects current game time (expiry/finalize/spawn run inside the tick). // read reflects current game time (expiry/finalize/spawn run inside the tick).
await tickNow(); await tickNow();
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) { } catch (err) {
console.error('failed to load game state', err); console.error('failed to load game state', err);
return Response.json({ error: 'failed to load game state' }, { status: 500 }); return Response.json({ error: 'failed to load game state' }, { status: 500 });
+6 -5
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -46,11 +46,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0]; // v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
if (!account) throw new Error('account row missing'); // player the e2e suite plays via mint-player-session's default).
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 }); const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+23
View File
@@ -0,0 +1,23 @@
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
// TEST-ONLY hook (v0.71.0) — mints a live session for the SEEDED admin user server-side and
// returns its id, so the e2e suite can exercise the authenticated Admin panel WITHOUT the real
// admin password ever appearing in test code (the spec forbids it). No body. Disabled unless
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.ci.yml for the CI stack only); every other
// deployment 404s here, like its /api/test/* siblings.
export async function POST() {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
const inst = await getGameDb();
const res = inst.raw.exec("SELECT id FROM users WHERE username = 'kamilkrzywda'");
const userId = res[0]?.values?.[0]?.[0];
if (typeof userId !== 'string') {
return Response.json({ error: 'seeded admin user missing' }, { status: 500 });
}
const sessionId = await createSession(inst, userId);
return Response.json({ sessionId });
}
+3 -3
View File
@@ -1,5 +1,6 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed'; import { generatePackage, mulberry32 } from '@/lib/game/seed';
@@ -84,8 +85,7 @@ export async function POST(req: Request) {
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality // v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// as engine spawns; never force a fixed value here. // as engine spawns; never force a fixed value here.
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
if (!account) throw new Error('account row missing');
const created = generatePackage({ const created = generatePackage({
id: randomUUID(), id: randomUUID(),
airports, airports,
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
weightKg: input.weightKg as number | undefined, weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null), special: input.special === undefined ? null : (input.special as string | null),
reputation: account.reputation, reputation: player.reputation,
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
@@ -0,0 +1,95 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { POST } from './route';
// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set
// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db');
});
afterAll(async () => {
vi.unstubAllEnvs();
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body?: unknown) =>
POST(
new Request('http://localhost/api/test/mint-player-session', {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
}),
);
describe('POST /api/test/mint-player-session', () => {
it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '');
const res = await post();
expect(res.status).toBe(404);
});
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
// The session resolves to the EXISTING admin user — no new users/players rows are created.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe('admin-kamilkrzywda');
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
const inst = await getGameDb();
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
expect(userRow.length).toBe(1);
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
});
it('rejects bad usernames and unknown fields with 400', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
// /api/test/admin-session). Creates the users + players rows if missing — idempotent across
// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every
// other deployment 404s here, like its /api/test/* siblings.
//
// POST /api/test/mint-player-session
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
// with the seeded world's planes by id, so the minted session must resolve to the local player
// that owns them after the owner_id backfill); a custom username creates a fresh test account
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
// Returns { sessionId }.
const DEFAULT_USERNAME = 'kamilkrzywda';
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
// server-side); a fixed value keeps the row debuggable from the admin panel.
const TEST_PASSWORD = 'e2e-mint-password-42';
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let username = DEFAULT_USERNAME;
if (input.username !== undefined) {
if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
username = input.username;
}
const inst = await getGameDb();
let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]);
let userId = res[0]?.values?.[0]?.[0];
if (typeof userId !== 'string') {
// New test account — one UUID for both rows, starter economy like a fresh world.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(TEST_PASSWORD),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
userId = id;
}
const sessionId = await createSession(inst, userId); // persists the rows + session together
return Response.json({ sessionId });
}
+6 -5
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -42,11 +42,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0]; // v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
if (!account) throw new Error('account row missing'); // the e2e suite plays via mint-player-session's default).
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 }); await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -57,7 +58,8 @@ export async function POST(req: Request) {
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -74,7 +75,8 @@ export async function POST(req: Request) {
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs); if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+210
View File
@@ -588,3 +588,213 @@ body {
font-weight: 600; font-weight: 600;
color: #f1f5f9; color: #f1f5f9;
} }
/* v0.71.0 — Admin modal: overlay sits above the z-50 toast stack; panel matches the dark slate theme. */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 60; /* above toasts (z-50) and everything else on screen */
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.7);
}
.modal-panel {
width: min(920px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, 0.97);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 8px;
box-shadow: 0 12px 40px rgba(2, 6, 23, 0.6);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
}
.modal-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
/* v0.71.0 — Admin panel content (login form + generic DB grid). */
.admin-panel {
padding: 14px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-login {
max-width: 320px;
margin: 0 auto;
width: 100%;
}
.admin-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #cbd5e1;
}
.admin-panel input[type='text'],
.admin-panel input:not([type]),
.admin-login input {
font-size: 13px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: #1e293b;
color: #e2e8f0;
}
.admin-message {
margin: 0;
font-size: 13px;
color: #cbd5e1;
}
.admin-error {
font-size: 12px;
color: #fca5a5;
}
.admin-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.admin-table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-rows-wrap {
overflow-x: auto;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 6px;
}
.admin-rows {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.admin-rows th,
.admin-rows td {
padding: 4px 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.admin-rows th {
color: #94a3b8;
font-weight: 600;
}
.admin-cell {
width: 110px;
font-size: 12px;
padding: 2px 5px;
border-radius: 3px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #1e293b;
color: #e2e8f0;
}
.admin-cell:disabled {
opacity: 0.55;
}
.admin-row-actions {
white-space: nowrap;
}
/* v1.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;
}
+47 -1
View File
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane'; import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane'; import RightPane from '@/components/RightPane';
import SignInScreen from '@/components/SignInScreen';
import ToastStack from '@/components/Toasts'; import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes'; import type { HoverTarget } from '@/components/hoverTypes';
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
import { useGameState } from '@/lib/useGameState'; import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts'; import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents'; import { diffSnapshots } from '@/lib/deriveEvents';
@@ -13,6 +15,50 @@ import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false }); const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() { export default function Home() {
// v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
// feed never polls an unauthenticated /api/state.
const [authed, setAuthed] = useState<boolean | null>(null);
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 // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately. // lets a pane push an action endpoint's full response snapshot in immediately.
@@ -49,7 +95,7 @@ export default function Home() {
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */} action response into the shared state, exactly like RightPane does */}
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} /> <Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} /> <RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<ToastStack toasts={toasts} onDismiss={dismiss} /> <ToastStack toasts={toasts} onDismiss={dismiss} />
</div> </div>
+403
View File
@@ -0,0 +1,403 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
interface Me {
username: string;
admin: boolean;
}
interface RowsData {
columns: string[];
rows: Array<Record<string, unknown>>;
}
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
const [session, setSession] = useState<string | null>(null);
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
const [checking, setChecking] = useState(true);
// Login form state.
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState<string | null>(null);
// Admin grid state.
const [tables, setTables] = useState<string[]>([]);
const [table, setTable] = useState<string | null>(null);
const [rowsData, setRowsData] = useState<RowsData | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
const [newRow, setNewRow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
// On mount: a stored session decides between the grid and the login form.
useEffect(() => {
let cancelled = false;
const stored = storedSession();
if (!stored) {
setChecking(false);
return;
}
fetch('/api/me', { headers: authHeaders(stored) })
.then(async (res) => {
if (!res.ok || cancelled) {
clearStoredSession();
setSession(null);
return;
}
const body = (await res.json()) as Me;
setSession(stored);
setMe(body);
})
.catch(() => {
if (!cancelled) {
clearStoredSession();
setSession(null);
}
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, [authHeaders]);
const loadTables = useCallback(async (s: string) => {
const res = await fetch('/api/admin/tables', { headers: authHeaders(s) });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name));
}, [authHeaders]);
const loadRows = useCallback(
async (s: string, name: string) => {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
setRowsData(await res.json());
},
[authHeaders],
);
// Enter the grid once /api/me confirms an admin.
useEffect(() => {
if (!session || !me?.admin) return;
let cancelled = false;
(async () => {
try {
await loadTables(session);
if (!cancelled) setBusy(false);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [session, me, loadTables]);
const openTable = async (name: string) => {
if (!session) return;
setBusy(true);
setError(null);
try {
await loadRows(session, name);
setTable(name);
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const refresh = async () => {
if (!session || !table) return;
setBusy(true);
setError(null);
try {
await loadRows(session, table);
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const login = async () => {
if (!username || !password) return;
setLoginError(null);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
return;
}
const body = (await res.json()) as { sessionId: string; user: Me };
storeSession(body.sessionId);
setSession(body.sessionId);
setMe(body.user);
setUsername('');
setPassword('');
};
const logout = async () => {
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
clearStoredSession();
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
setSession(null);
setMe(null);
setTable(null);
setRowsData(null);
setTables([]);
setError(null);
};
if (checking) return <div className="admin-panel">loading…</div>;
// No valid session → login form.
if (!session || !me) {
return (
<form
className="admin-panel admin-login"
onSubmit={(e) => {
e.preventDefault();
void login();
}}
>
<label>
Username
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input
data-testid="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{loginError && (
<div data-testid="admin-error" className="admin-error">
{loginError}
</div>
)}
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
Log in
</button>
</form>
);
}
// Authenticated but not an admin.
if (!me.admin) {
return (
<div className="admin-panel">
<p data-testid="admin-not-admin" className="admin-message">
Logged in as <strong>{me.username}</strong> — this account is not an admin.
</p>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
);
}
const columns = rowsData?.columns ?? [];
// Displayed grid: server rows plus one trailing empty row when "+ Add row" is active.
const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0);
const cellValue = (rowIdx: number, col: string): string => {
if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? '');
const key = `${rowIdx}:${col}`;
if (key in edits) return edits[key];
const raw = rowsData?.rows[rowIdx]?.[col];
return raw === null || raw === undefined ? '' : String(raw);
};
const saveRow = async (rowIdx: number) => {
if (!session || !table || !rowsData) return;
setBusy(true);
setError(null);
try {
if (newRow && rowIdx === rowsData.rows.length) {
// New row → POST the non-empty cells.
const values: Record<string, string> = {};
for (const col of columns) {
if (col === '__rowid') continue;
const v = cellValue(rowIdx, col);
if (v !== '') values[col] = v;
}
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders(session) },
body: JSON.stringify({ values }),
});
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
} else {
// Existing row → PUT only the changed cells (cleared cell = NULL).
const values: Record<string, string | null> = {};
for (const col of columns) {
if (col === '__rowid') continue;
const original = rowsData.rows[rowIdx]?.[col];
const origStr = original === null || original === undefined ? '' : String(original);
const v = cellValue(rowIdx, col);
if (v !== origStr) values[col] = v === '' ? null : v;
}
if (Object.keys(values).length === 0) return; // nothing changed
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...authHeaders(session) },
body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }),
});
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
}
await loadRows(session, table); // re-fetch so the grid shows server state (persistence check)
setEdits({});
setNewRow(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const deleteRow = async (rowIdx: number) => {
if (!session || !table || !rowsData?.rows[rowIdx]) return;
setBusy(true);
setError(null);
try {
const rowid = rowsData.rows[rowIdx]?.__rowid;
const res = await fetch(
`/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`,
{ method: 'DELETE', headers: authHeaders(session) },
);
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
await loadRows(session, table);
setEdits({});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="admin-panel">
<div className="admin-toolbar">
<span data-testid="admin-user" className="admin-message">
{me.username} (admin)
</span>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
{!table && (
<>
<p className="admin-message">Pick a table:</p>
<div data-testid="admin-tables" className="admin-table-list">
{tables.map((t) => (
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
{t}
</button>
))}
</div>
</>
)}
{table && rowsData && (
<>
<div className="admin-toolbar">
<strong data-testid="admin-current-table">{table}</strong>
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
← tables
</button>
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
Refresh
</button>
<button
type="button"
data-testid="admin-add-row"
className="pane-btn"
disabled={busy || newRow}
onClick={() => setNewRow(true)}
>
+ Add row
</button>
</div>
<div className="admin-rows-wrap">
<table data-testid="admin-rows" className="admin-rows">
<thead>
<tr>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
<th />
</tr>
</thead>
<tbody>
{Array.from({ length: rowCount }, (_, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c}>
<input
data-testid={`admin-cell-${i}-${c}`}
className="admin-cell"
value={cellValue(i, c)}
disabled={busy || c === '__rowid'}
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
/>
</td>
))}
<td className="admin-row-actions">
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
Save
</button>{' '}
{!(newRow && i === rowsData.rows.length) && (
<>
<button
type="button"
data-testid={`admin-delete-${i}`}
className="pane-btn"
disabled={busy}
onClick={() => void deleteRow(i)}
>
Delete
</button>{' '}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{error && (
<div data-testid="admin-error" className="admin-error">
{error}
</div>
)}
</div>
);
}
+19 -2
View File
@@ -11,6 +11,9 @@ import type { HoverTarget } from './hoverTypes';
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime'; import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal';
import AdminPanel from './AdminPanel';
import { sessionHeaders } from '../lib/authClient';
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the // v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, // Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
@@ -67,11 +70,14 @@ interface MapProps {
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared // ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
// shell state — same callback RightPane receives, used by the airport popup's Relocate row. // shell state — same callback RightPane receives, used by the airport popup's Relocate row.
applySnapshot: (snapshot: StateSnapshot) => void; applySnapshot: (snapshot: StateSnapshot) => void;
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
onLogout?: () => void;
} }
// Named FlightMap (not Map): a component called `Map` would shadow the global // Named FlightMap (not Map): a component called `Map` would shadow the global
// Map constructor inside its own body, breaking `new Map(...)` for the airport index. // Map constructor inside its own body, breaking `new Map(...)` for the airport index.
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event // /api/airports is fetched at most once per page life: every styledata event
@@ -89,6 +95,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null); const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null); const [airportCount, setAirportCount] = useState<number | null>(null);
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID); const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
// stored session on every open.
const [adminOpen, setAdminOpen] = useState(false);
// Push the latest planes/routes GeoJSON into whatever sources currently exist. // Push the latest planes/routes GeoJSON into whatever sources currently exist.
// No-op until the map (and for routes: the airport index) is ready — the mount // No-op until the map (and for routes: the airport index) is ready — the mount
@@ -290,7 +299,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const action = planeStatus === 'available' ? 'fly' : 'queue'; const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, { const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, // v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
body: JSON.stringify({ destIcao: p.icao }), body: JSON.stringify({ destIcao: p.icao }),
cache: 'no-store', cache: 'no-store',
}); });
@@ -579,8 +589,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
<span data-testid="airport-count"> <span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`} {airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span> </span>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin
</button>
</header> </header>
<div ref={containerRef} data-testid="map" className="map-container" /> <div ref={containerRef} data-testid="map" className="map-container" />
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel onLogout={onLogout} />
</Modal>
</div> </div>
); );
} }
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, type ReactNode } from 'react';
// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack),
// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it).
// Foundation for future modal-based features; keep this generic.
export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h2>{title}</h2>
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
×
</button>
</header>
{children}
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useState } from 'react';
import { storeSession } from '@/lib/authClient';
// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is
// stored. Both forms POST the same shape ({username, password}); login mints a session for an
// existing account, register creates one first and then mints it. On success the sessionId goes
// to localStorage (via storeSession) and onAuthed() flips the shell back in.
interface SignInScreenProps {
onAuthed: () => void;
}
export default function SignInScreen({ onAuthed }: SignInScreenProps) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent<HTMLFormElement>, path: '/api/login' | '/api/register') {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const form = new FormData(e.currentTarget);
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: String(form.get('username') ?? ''),
password: String(form.get('password') ?? ''),
}),
cache: 'no-store',
});
const data = (await res.json()) as { sessionId?: string; error?: string };
if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`);
storeSession(data.sessionId);
onAuthed();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="sign-in">
<h1 data-testid="app-title">Flight Ops</h1>
{error !== null && (
<p data-testid="auth-error" role="alert" className="sign-in-error">
{error}
</p>
)}
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
<label htmlFor="sign-in-username">Username</label>
<input id="sign-in-username" data-testid="sign-in-username" name="username" autoComplete="username" required />
<label htmlFor="sign-in-password">Password</label>
<input id="sign-in-password" data-testid="sign-in-password" name="password" type="password" autoComplete="current-password" required />
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
Sign in
</button>
</form>
<p className="sign-in-divider">or</p>
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
<input id="register-username" name="username" autoComplete="off" required />
<label htmlFor="register-password">Password (at least 8 characters)</label>
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
Create account
</button>
</form>
</div>
);
}
+5
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
// via the right pane → click its destination in the on-board list (left pane since // via the right pane → click its destination in the on-board list (left pane since
+113
View File
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';
// v0.71.0 — Admin panel e2e: top-bar button → modal; wrong credentials show an error; a session
// minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates WITHOUT the real
// admin password ever appearing in test code; then the generic DB grid is exercised end-to-end:
// table list → edit + save a cell (verified by an independent re-fetch) → add a row → delete it →
// logout. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated
// visitors see IT instead of the shell, and logout drops back to it.
const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
async function mintAdminSession(page: import('@playwright/test').Page): Promise<string> {
const res = await page.request.post('/api/test/admin-session');
expect(res.status(), 'admin-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
return ((await res.json()) as { sessionId: string }).sessionId;
}
test('admin panel: sign-in gate, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => {
await page.goto('/');
// --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107).
const signIn = page.getByTestId('sign-in-form');
await expect(signIn).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('app-header')).toHaveCount(0);
// --- Wrong credentials → error line (the seeded admin's plaintext is never used here).
await page.getByTestId('sign-in-username').fill('kamilkrzywda');
await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose');
await page.getByTestId('sign-in-submit').click();
await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 });
// --- Authenticate via the test hook + localStorage, then reload into the shell.
const sessionId = await mintAdminSession(page);
// Both values must be passed as arguments — the function runs in the browser, where Node-side
// variables are not in scope.
await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]);
// The independent re-fetch below goes through page.request → needs the header at context level.
await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId });
await page.reload();
const adminBtn = page.getByTestId('admin-btn');
await expect(adminBtn).toBeVisible({ timeout: 30_000 });
// --- Open the modal: stored session is valid → straight to the table list.
await adminBtn.click();
// --- Authenticated admin → table list including packages and users.
const tables = page.getByTestId('admin-tables');
await expect(tables).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('admin-table-packages')).toBeVisible();
await expect(page.getByTestId('admin-table-users')).toBeVisible();
// --- Open packages, edit one cell, Save; verify via an INDEPENDENT re-fetch of the same route.
await page.getByTestId('admin-table-packages').click();
const grid = page.getByTestId('admin-rows');
await expect(grid).toBeVisible({ timeout: 30_000 });
const rowidCell = page.getByTestId('admin-cell-0-__rowid');
const originalRowid = Number(await rowidCell.inputValue());
expect(Number.isInteger(originalRowid)).toBeTruthy();
const rewardCell = page.getByTestId('admin-cell-0-reward');
await rewardCell.fill('987654');
await page.getByTestId('admin-save-0').click();
// The grid re-fetches after save — the edited cell shows the new value…
await expect(rewardCell).toHaveValue('987654', { timeout: 15_000 });
// …and an independent request (not the grid's) confirms it persisted server-side.
const check = await page.request.get('/api/admin/tables/packages/rows?limit=500', {
headers: { 'X-Flight-Session': sessionId },
});
expect(check.status()).toBe(200);
const fetched = (await check.json()) as { rows: Array<Record<string, unknown>> };
const row = fetched.rows.find((r) => Number(r.__rowid) === originalRowid);
expect(row, 'edited packages row must still exist').toBeTruthy();
expect(Number(row?.reward)).toBe(987654);
// --- Add a row (users table — fewest required columns), then delete it again.
await page.getByTestId('admin-back').click();
await expect(tables).toBeVisible();
await page.getByTestId('admin-table-users').click();
const usersGrid = page.getByTestId('admin-rows');
await expect(usersGrid).toBeVisible({ timeout: 30_000 });
const existingRows = await page.getByTestId(/admin-save-\d+/).count();
await page.getByTestId('admin-add-row').click();
const n = existingRows; // the new empty row is appended last
await page.getByTestId(`admin-cell-${n}-id`).fill('user-e2e-admin-test');
await page.getByTestId(`admin-cell-${n}-username`).fill('e2e-admin-user');
await page.getByTestId(`admin-cell-${n}-password_hash`).fill('aa$bb'); // any string — dev-tool insert, not a real credential
await page.getByTestId(`admin-cell-${n}-created_at`).fill(String(Date.now()));
await page.getByTestId(`admin-save-${n}`).click();
// The inserted row is visible after the grid's post-save re-fetch.
const bodyRows = usersGrid.locator('tbody tr');
const findUserRow = async (): Promise<number> => {
const count = await bodyRows.count();
for (let i = 0; i < count; i++) {
if ((await page.getByTestId(`admin-cell-${i}-username`).inputValue()) === 'e2e-admin-user') return i;
}
return -1;
};
await expect.poll(findUserRow, { timeout: 15_000 }).toBeGreaterThanOrEqual(0);
const addedIdx = await findUserRow();
// Delete it again — gone after the post-delete re-fetch.
await page.getByTestId(`admin-delete-${addedIdx}`).click();
await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1);
// --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell).
await page.getByTestId('admin-logout').click();
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
await expect(tables).toBeHidden();
});
+9 -2
View File
@@ -13,25 +13,32 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all eighteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> }; const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
expect(spec.info.title).toBe('Flight Ops API'); expect(spec.info.title).toBe('Flight Ops API');
expect(spec.info.version).toBe(pkg.version); expect(spec.info.version).toBe(pkg.version);
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly. // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly;
// v0.71.0 adds the admin panel (login/logout/me + guarded DB CRUD).
expect(Object.keys(spec.paths).sort()).toEqual([ expect(Object.keys(spec.paths).sort()).toEqual([
'/api/admin/tables', // v0.71.0 — admin panel
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/login', // v0.71.0 — admin panel auth
'/api/logout', // v0.71.0
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel', '/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
+31
View File
@@ -0,0 +1,31 @@
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;
}
+8 -2
View File
@@ -15,8 +15,14 @@ interface StateBody {
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame. // (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']); const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
test('GET /api/events streams state snapshots over SSE', async () => { test('GET /api/events streams state snapshots over SSE', async ({ request }) => {
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } }); // v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client
// passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path.
const mint = await request.post('/api/test/mint-player-session');
expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
const { sessionId } = (await mint.json()) as { sessionId: string };
const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.body).not.toBeNull(); expect(res.body).not.toBeNull();
+5
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map. // v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying); // Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
// open-package rows highlight both endpoint airports. The map exposes three top-most // open-package rows highlight both endpoint airports. The map exposes three top-most
+5
View File
@@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test';
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants. // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
import { REPAIR_COST } from '../lib/game/catalog'; import { REPAIR_COST } from '../lib/game/catalog';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live // v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows // origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts // the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
+5
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"): // v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
// expanded airport popup (name + ICAO kept, plus approximate local time and a // expanded airport popup (name + ICAO kept, plus approximate local time and a
// deterministic generated weather), plane markers whose positions come from the // deterministic generated weather), plane markers whose positions come from the
+5
View File
@@ -2,6 +2,11 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
const APP_URL = '/'; const APP_URL = '/';
// Read at test time so the assertion tracks package.json automatically. // Read at test time so the assertion tracks package.json automatically.
+5
View File
@@ -1,5 +1,10 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts). // The panes render from the shell's single /api/state poll (lib/useGameState.ts).
+5
View File
@@ -7,6 +7,11 @@ import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement'; import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and // while flying, ISS-053), and
+5
View File
@@ -1,6 +1,11 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. // v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin.
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0) // been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
+5
View File
@@ -9,6 +9,11 @@ import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/mo
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed'; import { STARTING_REPUTATION } from '../lib/game/seed';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination + // (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
+5
View File
@@ -4,6 +4,11 @@ import { test, expect, type Page } from '@playwright/test';
// pool, at EDDF. // pool, at EDDF.
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed'; import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully // v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is // grounded fleet freezes the clock, background-tick advance at a raised rate while something is
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and // active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
+5
View File
@@ -2,6 +2,11 @@ import { execSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on // Repo root — docker-compose.yml lives here; `docker compose restart` runs on
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
// isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one. // isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one.
+5
View File
@@ -1,6 +1,11 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the // v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
// right pane, in addition to the inline pane error) and game events appear as info toasts via // right pane, in addition to the inline pane error) and game events appear as info toasts via
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in // snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
+5
View File
@@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
import { authenticate } from './auth';
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
test.beforeEach(async ({ page }) => { await authenticate(page); });
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path // greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { createSession, hashPassword, requireAuth, verifyPassword } from './auth';
// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as
// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const reqWith = (sessionId?: string) =>
new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} });
describe('hashPassword / verifyPassword', () => {
it('round-trips a password through the stored digest', () => {
const hash = hashPassword('correct-horse-battery-staple');
expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's
expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true);
});
it('rejects a wrong password and malformed digests', () => {
const hash = hashPassword('some-password');
expect(verifyPassword('other-password', hash)).toBe(false);
expect(verifyPassword('x', 'not-a-digest')).toBe(false);
expect(verifyPassword('x', '')).toBe(false);
});
});
describe('requireAuth', () => {
it('401s when the header is missing or the session id is unknown', async () => {
for (const req of [reqWith(), reqWith('no-such-session')]) {
const guard = await requireAuth(req);
expect(guard.ok).toBe(false);
if (!guard.ok) {
expect(guard.response.status).toBe(401);
expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i);
}
}
});
it('401s an expired session', async () => {
const inst = await getGameDb();
inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]);
const guard = await requireAuth(reqWith('expired-sess'));
expect(guard.ok).toBe(false);
if (!guard.ok) expect(guard.response.status).toBe(401);
});
it('resolves the seeded admin session to the local player row (joined on username)', async () => {
const inst = await getGameDb();
const sessionId = await createSession(inst, 'admin-kamilkrzywda');
const guard = await requireAuth(reqWith(sessionId));
expect(guard.ok).toBe(true);
if (guard.ok) {
expect(guard.user.username).toBe('kamilkrzywda');
expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row
expect(guard.player.cash).toBe(20_000); // starter economy from the seed
}
});
it('500s when the account has no players row (broken data, not a client error)', async () => {
const inst = await getGameDb();
inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [
hashPassword('whatever-123'),
Date.now(),
]);
const sessionId = await createSession(inst, 'user-no-player');
const guard = await requireAuth(reqWith(sessionId));
expect(guard.ok).toBe(false);
if (!guard.ok) {
expect(guard.response.status).toBe(500);
expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i);
}
});
});
+122
View File
@@ -0,0 +1,122 @@
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import type { PlayerRow } from './db/schema';
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
// a single-origin dev tool surface, not a public auth system.
export const SESSION_HEADER = 'x-flight-session';
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
// Fixed digest compared against when the username is unknown, so every failed login runs one
// full scrypt and the timing shape stays uniform (no user-existence oracle).
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
export interface AuthUser {
id: string;
username: string;
admin: boolean;
}
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
// 32-byte hash). Malformed digests never match.
export function verifyPassword(password: string, storedHash: string): boolean {
const [saltHex, hashHex] = storedHash.split('$');
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
const computed = scryptSync(password, saltHex, 32).toString('hex');
try {
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
} catch {
return false;
}
}
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
// verifies through verifyPassword().
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
}
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
// doesn't silently log everyone out mid-session.
export async function createSession(inst: GameDb, userId: string): Promise<string> {
const id = randomUUID();
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
id,
userId,
Date.now() + SESSION_TTL_MS,
]);
await persist(inst);
return id;
}
// Resolve a session id to a live (non-expired) user, or null.
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
const res = inst.raw.exec(
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
[sessionId, Date.now()],
);
const row = res[0]?.values?.[0];
if (!row) return null;
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
}
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
export async function currentUser(req: Request): Promise<AuthUser | null> {
const sessionId = req.headers.get(SESSION_HEADER);
if (!sessionId) return null;
return resolveSession(await getGameDb(), sessionId);
}
export type AuthGuard =
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
| { ok: false; response: Response };
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
// (register and the mint hook always create both together — a miss means broken data).
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
const inst = await getGameDb();
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
const row = res[0]?.values?.[0];
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
return {
ok: true,
inst,
user,
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
};
}
export async function requireAuth(req: Request): Promise<AuthGuard> {
return finishAuth(await currentUser(req));
}
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
// ?session= query parameter (private infra; every other game route is header-only).
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
if (!sessionId) return finishAuth(null);
return finishAuth(await resolveSession(await getGameDb(), sessionId));
}
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
// No/expired session → 401, valid non-admin session → 403.
export async function requireAdmin(req: Request): Promise<AdminGuard> {
const user = await currentUser(req);
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
if (!user.admin) {
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
}
return { ok: true, inst: await getGameDb(), user };
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
// v1.1.0 (ISS-107) — client-side session handling shared by every authenticated fetcher, the
// sign-in screen and the admin panel. The server mints sessions at /api/login + /api/register;
// we only store the id in localStorage and echo it back as X-Flight-Session.
export const SESSION_STORAGE_KEY = 'flight.session';
export function storedSession(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null; // storage blocked (private mode) — treat as signed out
}
}
// Headers for an authenticated fetch. Empty object when no session is stored, so callers can
// spread it into their existing header objects without branching.
export function sessionHeaders(session: string | null = storedSession()): Record<string, string> {
return session ? { 'X-Flight-Session': session } : {};
}
export function storeSession(sessionId: string): void {
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
}
export function clearStoredSession(): void {
try {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
// storage blocked — nothing to clear
}
}
+180 -11
View File
@@ -2,12 +2,17 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import initSqlJs from 'sql.js'; import initSqlJs from 'sql.js';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CLOCK_MS } from '@/lib/game/seed'; import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
import { openGameDb, persist } from './client'; import { openGameDb, persist } from './client';
import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer';
import * as schema from './schema'; import * as schema from './schema';
// v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry.
const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' };
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. // The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
const LEGACY_DDL = ` const LEGACY_DDL = `
CREATE TABLE planes ( CREATE TABLE planes (
@@ -84,6 +89,40 @@ CREATE TABLE sim_state (
); );
`; `;
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
// file for the owner_id backfill migration test.
const PRE_OWNERSHIP_DDL = `
CREATE TABLE planes (
id TEXT PRIMARY KEY NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]',
health REAL NOT NULL DEFAULT 100,
repair_until_sim_time INTEGER
);
CREATE TABLE players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL,
reputation REAL NOT NULL
);
CREATE TABLE sim_state (
clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL,
paused INTEGER NOT NULL,
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1
);
`;
let dir: string; let dir: string;
beforeAll(async () => { beforeAll(async () => {
@@ -110,8 +149,13 @@ describe('openGameDb', () => {
expect(packages.length).toBeGreaterThanOrEqual(8); expect(packages.length).toBeGreaterThanOrEqual(8);
expect(packages.every((p) => p.status === 'open')).toBe(true); expect(packages.every((p) => p.status === 'open')).toBe(true);
const account = (await inst.db.select().from(schema.account))[0]; // v1.0.0 (ISS-105): the singleton economy is now a seeded local player row…
expect(account?.cash).toBeGreaterThan(0); expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// …and fresh DBs never create an `account` table.
const tables = new Set(
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate. // ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS); expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
@@ -126,25 +170,25 @@ describe('openGameDb', () => {
const file = path.join(dir, 'reopen.db'); const file = path.join(dir, 'reopen.db');
const first = await openGameDb(file); const first = await openGameDb(file);
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort(); const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
const account1 = (await first.db.select().from(schema.account))[0]; const player1 = await getLocalPlayer(first);
// Simulate a process restart: brand-new instance over the same file. // Simulate a process restart: brand-new instance over the same file.
const second = await openGameDb(file); const second = await openGameDb(file);
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort(); const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
expect(packages2).toEqual(packages1); expect(packages2).toEqual(packages1);
expect((await second.db.select().from(schema.account))[0]).toEqual(account1); expect(await getLocalPlayer(second)).toEqual(player1);
}); });
it('persist() durably saves writes made after open', async () => { it('persist() durably saves writes made after open', async () => {
const file = path.join(dir, 'write.db'); const file = path.join(dir, 'write.db');
const inst = await openGameDb(file); const inst = await openGameDb(file);
await inst.db.update(schema.account).set({ cash: 12345 }); await updateLocalPlayerEconomy(inst, { cash: 12345 });
await persist(inst); await persist(inst);
// A fresh instance over the same file sees the update. // A fresh instance over the same file sees the update.
const reloaded = await openGameDb(file); const reloaded = await openGameDb(file);
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345); expect((await getLocalPlayer(reloaded)).cash).toBe(12345);
}); });
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
@@ -161,8 +205,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file); const inst = await openGameDb(file);
// Not re-seeded (account row exists)… // Not re-seeded (the legacy account row was migrated into the local player)…
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); expect((await getLocalPlayer(inst)).cash).toBe(20000);
// …but the new columns exist and are usable… // …but the new columns exist and are usable…
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane?.departureSimTime ?? null).toBeNull(); expect(plane?.departureSimTime ?? null).toBeNull();
@@ -194,8 +238,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file); const inst = await openGameDb(file);
// Wallet + sim clock survive byte-identical… // Wallet (migrated into the local player) + sim clock survive byte-identical…
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 }); expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605); expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)… // …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
@@ -222,6 +266,77 @@ describe('openGameDb', () => {
expect(cols.has('dest_icao')).toBe(true); expect(cols.has('dest_icao')).toBe(true);
}); });
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
// The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(),
// so it never verified; production DBs seeded by that version carry exactly this value.
const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
const file = path.join(dir, 'admin-seed.db');
const inst = await openGameDb(file);
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
// Simulate a process restart: brand-new instance over the same file.
const second = await openGameDb(file);
rows = second.raw.exec('SELECT COUNT(*) FROM users');
expect(Number(rows[0].values[0][0])).toBe(1);
});
it('seeds the admin into a legacy DB that predates the users table', async () => {
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(LEGACY_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
const file = path.join(dir, 'legacy-admin.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
// The game world is untouched by the seed (its account row migrated to the local player).
expect((await getLocalPlayer(inst)).cash).toBe(20000);
});
it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => {
// A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(`CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)`);
raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [
'admin-kamilkrzywda',
'kamilkrzywda',
LEGACY_HASH,
Date.now(),
]);
const file = path.join(dir, 'admin-rotate.db');
await fs.writeFile(file, Buffer.from(raw.export()));
// First open heals the row to the current digest…
const first = await openGameDb(file);
let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
expect(rows[0].values).toHaveLength(1);
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
// …and a second open is a no-op (idempotent rotation).
const second = await openGameDb(file);
rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'");
expect(rows[0].values[0][0]).toBe(ADMIN_HASH);
});
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => { it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
const file = path.join(dir, 'pre-v12-reopen.db'); const file = path.join(dir, 'pre-v12-reopen.db');
const SQL = await initSqlJs(); const SQL = await initSqlJs();
@@ -244,4 +359,58 @@ describe('openGameDb', () => {
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD'); expect(plane.destIcao).toBe('LEMD');
}); });
it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => {
// Minimal legacy file — just an `account` table; open() creates everything else from DDL.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`);
raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`);
const file = path.join(dir, 'pre-v1.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
let tables = new Set(
inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
// Second open is a no-op (idempotent): the row survives unchanged, still exactly one player.
const second = await openGameDb(file);
expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 });
tables = new Set(
second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
expect(tables.has('account')).toBe(false);
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(PRE_OWNERSHIP_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
const file = path.join(dir, 'pre-ownership.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
// Every legacy plane is backfilled to the local player…
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners).toEqual(['local', 'local']);
// …and the world was NOT re-seeded (the players row already existed).
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
// Second open is a no-op: an owner change made after the migration survives a reopen.
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
await persist(inst);
const second = await openGameDb(file);
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners2).toEqual(['local', 'someone-else']);
});
}); });
+79 -6
View File
@@ -5,6 +5,7 @@ import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js'; import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { buildSeed, mulberry32 } from '@/lib/game/seed'; import { buildSeed, mulberry32 } from '@/lib/game/seed';
import { LOCAL_PLAYER } from './localPlayer';
import * as schema from './schema'; import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the // SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
@@ -22,6 +23,9 @@ export interface GameDb {
const DDL = ` const DDL = `
CREATE TABLE IF NOT EXISTS planes ( CREATE TABLE IF NOT EXISTS planes (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
owner_id TEXT NOT NULL,
model TEXT NOT NULL, model TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
fuel REAL NOT NULL, fuel REAL NOT NULL,
@@ -51,7 +55,12 @@ CREATE TABLE IF NOT EXISTS packages (
created_real_ms INTEGER NOT NULL DEFAULT 0, created_real_ms INTEGER NOT NULL DEFAULT 0,
slot_free_at_real_ms INTEGER slot_free_at_real_ms INTEGER
); );
CREATE TABLE IF NOT EXISTS account ( -- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local
-- single-player row is seeded by seedEmpty() or migrated from account in open().
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL, cash REAL NOT NULL,
reputation REAL NOT NULL reputation REAL NOT NULL
); );
@@ -62,8 +71,46 @@ CREATE TABLE IF NOT EXISTS sim_state (
last_real_tick_ms INTEGER NOT NULL DEFAULT 0, last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1 last_selected_time_scale REAL NOT NULL DEFAULT 1
); );
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
`; `;
// v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the
// operator's head — never in code, tests or comments; login verifies against this string.
// v0.72.0: recomputed with exactly the same call as verifyPassword() — salt passed as a
// plain string, scryptSync(pw, saltHex, 32) — because the v0.71.0 digest used different
// parameters and real logins failed with 401.
const ADMIN_SEED_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf';
// v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so
// pre-existing production DBs gain exactly one admin row and never a duplicate.
function seedAdminUser(raw: RawSqlJsDatabase): void {
raw.run(
`INSERT INTO users (id, username, password_hash, admin, created_at)
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
[ADMIN_SEED_HASH, Date.now()],
);
// v0.72.0: v0.71.0 seeded an incompatible digest into production DBs and the INSERT above
// can't overwrite them (WHERE NOT EXISTS), so rotate any legacy row to the current digest
// at boot — a no-op once healed, durable on the next persist().
raw.run(
`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`,
[ADMIN_SEED_HASH, ADMIN_SEED_HASH],
);
}
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF // v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs // NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
// already have everything from DDL above and this is a no-op for them. // already have everything from DDL above and this is a no-op for them.
@@ -115,6 +162,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1'); raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale'); raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
} }
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
// constant default, which SQLite applies to every existing row at ALTER time).
if (!cols('planes').has('owner_id')) {
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
}
} }
// v0.12.0 one-time migration: contracts world → packages world. Detected by the // v0.12.0 one-time migration: contracts world → packages world. Detected by the
@@ -144,6 +197,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
raw.exec('DROP TABLE contracts'); raw.exec('DROP TABLE contracts');
} }
// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop
// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have
// `account`, so this runs exactly once per legacy file and is a no-op on later opens.
function migrateAccountToPlayers(raw: RawSqlJsDatabase): void {
raw.run(
'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account',
[LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username],
);
raw.exec('DROP TABLE account');
}
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead // FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
// of the real ./db/flight.db. // of the real ./db/flight.db.
export function defaultDbPath(): string { export function defaultDbPath(): string {
@@ -161,6 +225,7 @@ async function open(file: string): Promise<GameDb> {
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL); // no-op for tables that already exist in an older file raw.exec(DDL); // no-op for tables that already exist in an older file
migrateColumns(raw); migrateColumns(raw);
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so // v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
// without this it would keep the dead contracts table forever. // without this it would keep the dead contracts table forever.
@@ -170,12 +235,18 @@ async function open(file: string): Promise<GameDb> {
if (tables.has('contracts')) { if (tables.has('contracts')) {
migrateContractsToPackages(raw); migrateContractsToPackages(raw);
} }
// v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a
// seeded local player and drop it. The players-empty guard makes this idempotent even if a file
// somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file.
const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0);
if (tables.has('account') && playerCount === 0) {
migrateAccountToPlayers(raw);
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First-run seed: only when the DB is empty (no account row yet). // First-run seed: only when the DB is empty (no player row yet).
const res = raw.exec('SELECT COUNT(*) FROM account'); if (playerCount === 0 && !tables.has('account')) {
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`); console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance); await seedEmpty(instance);
} }
@@ -205,9 +276,11 @@ async function seedEmpty(instance: GameDb): Promise<void> {
// SeedPlane.queue is a string[]; the column stores it as JSON text. // SeedPlane.queue is a string[]; the column stores it as JSON text.
const { queue, ...planeCols } = data.plane; const { queue, ...planeCols } = data.plane;
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) }); // v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
await instance.db.insert(schema.packages).values(data.packages); await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account); // v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
await instance.db.insert(schema.simState).values(data.simState); await instance.db.insert(schema.simState).values(data.simState);
await persist(instance); await persist(instance);
} }
+40
View File
@@ -0,0 +1,40 @@
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, the SSE broadcast account) and env-gated /api/test/* admin hooks.
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));
}
+32 -2
View File
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', { export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(), id: text('id').primaryKey().notNull(),
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
// onboarding stamp it explicitly on insert.
ownerId: text('owner_id').notNull(),
model: text('model').notNull(), model: text('model').notNull(),
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the // v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
// engine flips it back to 'available' on a tick. All status guards key off 'available'. // engine flips it back to 'available' on a tick. All status guards key off 'available'.
@@ -66,7 +70,13 @@ export const packages = sqliteTable('packages', {
slotFreeAtRealMs: integer('slot_free_at_real_ms'), slotFreeAtRealMs: integer('slot_free_at_real_ms'),
}); });
export const account = sqliteTable('account', { // v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local
// single-player row (id 'local') is seeded on first run / migrated from `account` by open();
// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only.
export const players = sqliteTable('players', {
id: text('id').primaryKey().notNull(),
provider: text('provider').notNull(),
username: text('username').notNull(),
cash: real('cash').notNull(), cash: real('cash').notNull(),
reputation: real('reputation').notNull(), reputation: real('reputation').notNull(),
}); });
@@ -84,7 +94,27 @@ export const simState = sqliteTable('sim_state', {
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1), lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
}); });
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
export const users = sqliteTable('users', {
id: text('id').primaryKey().notNull(),
username: text('username').notNull().unique(),
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
passwordHash: text('password_hash').notNull(),
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at').notNull(),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey().notNull(),
userId: text('user_id').notNull(),
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
expiresAt: integer('expires_at').notNull(),
});
export type PlaneRow = typeof planes.$inferSelect; export type PlaneRow = typeof planes.$inferSelect;
export type PackageRow = typeof packages.$inferSelect; export type PackageRow = typeof packages.$inferSelect;
export type AccountRow = typeof account.$inferSelect; export type PlayerRow = typeof players.$inferSelect;
export type SimStateRow = typeof simState.$inferSelect; export type SimStateRow = typeof simState.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
+1 -9
View File
@@ -64,7 +64,7 @@ describe('diffSnapshots', () => {
expect(diffSnapshots(prev, next)).toEqual([]); expect(diffSnapshots(prev, next)).toEqual([]);
}); });
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => { it('returns [] for the first snapshot (no baseline to diff against)', () => {
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] }); const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(null, next)).toEqual([]); expect(diffSnapshots(null, next)).toEqual([]);
}); });
@@ -113,14 +113,6 @@ describe('diffSnapshots', () => {
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]); expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
}); });
it('emits a new-package event for ids only in the next snapshot', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
]);
});
it('emits bought/sold events when the plane id set changes (with the model name)', () => { it('emits bought/sold events when the plane id set changes (with the model name)', () => {
const bought = diffSnapshots( const bought = diffSnapshots(
snapshot(), snapshot(),
+4 -9
View File
@@ -15,8 +15,7 @@ export interface GameEvent {
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model; const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] { export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a // No baseline yet (first snapshot after load) — nothing to diff against, so no events.
// "new package" event for every existing row would spam the stack on page load.
if (prev === null) return []; if (prev === null) return [];
const events: GameEvent[] = []; const events: GameEvent[] = [];
@@ -40,16 +39,12 @@ export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot):
} }
} }
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired // Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never // (open|loaded → expired). Ids that appear for the first time emit nothing.
// deleted, so an id appearing is always a genuinely new one).
const prevPackages = new Map(prev.packages.map((p) => [p.id, p])); const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) { for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id); const before = prevPackages.get(pkg.id);
if (!before) { if (!before) continue;
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (before.status === 'loaded' && pkg.status === 'delivered') { if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` }); events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') { } else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
+198 -116
View File
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client'; import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
@@ -21,7 +22,7 @@ import {
repRate, repRate,
SELL_FRACTION, SELL_FRACTION,
} from './catalog'; } from './catalog';
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed'; import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
import { import {
abandonPackage, abandonPackage,
buyPlane, buyPlane,
@@ -93,7 +94,7 @@ describe('loadPackage', () => {
const inst = await freshDb('load-ok.db'); const inst = await freshDb('load-ok.db');
await insertOpenPackage(inst); await insertOpenPackage(inst);
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
expect(row.status).toBe('loaded'); expect(row.status).toBe('loaded');
@@ -106,19 +107,19 @@ describe('loadPackage', () => {
const inst = await freshDb(`load-${status}.db`); const inst = await freshDb(`load-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status }); await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status)); await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
}, },
); );
it('rejects an unknown package id with 404', async () => { it('rejects an unknown package id with 404', async () => {
const inst = await freshDb('load-punknown.db'); const inst = await freshDb('load-punknown.db');
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404); await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('load-ghost.db'); const inst = await freshDb('load-ghost.db');
await insertOpenPackage(inst); await insertOpenPackage(inst);
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404); await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
}); });
it('rejects a flying plane with 409', async () => { it('rejects a flying plane with 409', async () => {
@@ -128,7 +129,7 @@ describe('loadPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
}); });
it('rejects a plane that is not at the origin with 409', async () => { it('rejects a plane that is not at the origin with 409', async () => {
@@ -136,7 +137,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst); // originates at EDDF await insertOpenPackage(inst); // originates at EDDF
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
}); });
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => { it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
@@ -148,7 +149,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk }); await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
} }
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
}); });
it('counts expired packages as occupying their payload too', async () => { it('counts expired packages as occupying their payload too', async () => {
@@ -159,7 +160,7 @@ describe('loadPackage', () => {
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half }); await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half }); await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
}); });
}); });
@@ -167,7 +168,7 @@ describe('flyPlane', () => {
it('sets flying + departure at current clock + dest_icao + origin coords', async () => { it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
const inst = await freshDb('fly-ok.db'); const inst = await freshDb('fly-ok.db');
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -187,38 +188,38 @@ describe('flyPlane', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409); await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('fly-ghost.db'); const inst = await freshDb('fly-ghost.db');
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404); await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
}); });
it('rejects an ICAO not in the airport dataset with 400', async () => { it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('fly-unknownicao.db'); const inst = await freshDb('fly-unknownicao.db');
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
}); });
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => { it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank 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')); await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /broken \(health 0\)/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded… expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched… expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and no cash left the account
}); });
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => { it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // no 409 — worn is not broken
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -249,10 +250,10 @@ describe('flyPlane', () => {
const startFuel = Math.round(required / 2); // partial tank below the requirement 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')); 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. // Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 }); await updateLocalPlayerEconomy(inst, { reputation: 30 });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
@@ -263,22 +264,22 @@ describe('flyPlane', () => {
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target… expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost expect(player.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
}); });
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => { 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 inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
const required = await adjustedRequiredForHubToLemd(); const required = await adjustedRequiredForHubToLemd();
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched… expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and free
}); });
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => { it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
@@ -286,9 +287,9 @@ describe('flyPlane', () => {
const required = await adjustedRequiredForHubToLemd(); const required = await adjustedRequiredForHubToLemd();
const startFuel = Math.round(required / 2); const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 0 }); await updateLocalPlayerEconomy(inst, { cash: 0 });
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /insufficient funds/i);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded… expect(plane.status).toBe('available'); // still grounded…
@@ -300,17 +301,17 @@ describe('flyPlane', () => {
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable. // UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD', 'local'), 409, /insufficient fuel/i);
}); });
// --- Queue consumption on dispatch (v0.46.0, ISS-088) --- // --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => { it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db'); const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -320,10 +321,10 @@ describe('flyPlane', () => {
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => { it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db'); const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
@@ -336,11 +337,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('appends legs to the queue in order and persists them as JSON', async () => { it('appends legs to the queue in order and persists them as JSON', async () => {
const inst = await freshDb('qleg-ok.db'); const inst = await freshDb('qleg-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
let plane = (await inst.db.select().from(schema.planes))[0]; let plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
plane = (await inst.db.select().from(schema.planes))[0]; plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
}); });
@@ -351,7 +352,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // still on its active leg… expect(plane.status).toBe('flying'); // still on its active leg…
@@ -361,19 +362,19 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qleg-ghost.db'); const inst = await freshDb('qleg-ghost.db');
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404); await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
}); });
it('rejects an ICAO not in the airport dataset with 400', async () => { it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('qleg-unknownicao.db'); const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
}); });
it('rejects a destination already in the queue with 409 (ISS-065)', async () => { it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db'); const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/); await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
@@ -385,7 +386,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/); await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
@@ -393,8 +394,8 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
it('still appends a different known airport with success (ISS-065 sanity)', async () => { it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db'); const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined(); await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
@@ -404,11 +405,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
it('removes the leg at the given index and keeps the rest in order', async () => { it('removes the leg at the given index and keeps the rest in order', async () => {
const inst = await freshDb('qcancel-ok.db'); const inst = await freshDb('qcancel-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
await queueLeg(inst, 'plane-starter', 'EDDV'); await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg) await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']); expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
@@ -416,19 +417,19 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
it('rejects an out-of-range index with 400', async () => { it('rejects an out-of-range index with 400', async () => {
const inst = await freshDb('qcancel-oob.db'); const inst = await freshDb('qcancel-oob.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
}); });
it('rejects cancelling from an empty queue with 400', async () => { it('rejects cancelling from an empty queue with 400', async () => {
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
}); });
it('rejects an unknown plane id with 404', async () => { it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qcancel-ghost.db'); const inst = await freshDb('qcancel-ghost.db');
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404); await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
}); });
}); });
@@ -441,7 +442,7 @@ describe('abandonPackage', () => {
const inst = await freshDb(name); const inst = await freshDb(name);
// Always insert OPEN and load it through the real action… // Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS }); await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
await loadPackage(inst, 'p-ab', 'plane-starter'); await loadPackage(inst, 'p-ab', 'plane-starter', 'local');
// …then flip to expired in place (what the tick would do once the deadline passes). // …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') { if (status === 'expired') {
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab')); await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
@@ -451,23 +452,23 @@ describe('abandonPackage', () => {
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => { it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
const inst = await setupLoaded('ab-start.db'); const inst = await setupLoaded('ab-start.db');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100); expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned'); expect(row.status).toBe('abandoned');
expect(row.planeId).toBeNull(); // slot freed expect(row.planeId).toBeNull(); // slot freed
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6); expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
}); });
it('scales to exactly 37.5% at the midpoint of the window', async () => { it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db'); const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100); expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
}); });
@@ -480,31 +481,31 @@ describe('abandonPackage', () => {
const inst = await setupLoaded(name); const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock }); await inst.db.update(schema.simState).set({ clockMs: clock });
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100); expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
} }
}); });
it('is free for an already-expired package', async () => { it('is free for an already-expired package', async () => {
const inst = await setupLoaded('ab-expired.db', 'expired'); const inst = await setupLoaded('ab-expired.db', 'expired');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(0); expect(penalty).toBe(0);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned'); expect(row.status).toBe('abandoned');
}); });
it('floors cash at €0 — no debt', async () => { it('floors cash at €0 — no debt', async () => {
const inst = await setupLoaded('ab-floor.db'); const inst = await setupLoaded('ab-floor.db');
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10 await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
expect(penalty).toBe(150); expect(penalty).toBe(150);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0); expect((await getLocalPlayer(inst)).cash).toBe(0);
}); });
it('rejects abandoning while the plane is in flight with 409', async () => { it('rejects abandoning while the plane is in flight with 409', async () => {
@@ -513,32 +514,32 @@ describe('abandonPackage', () => {
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i); await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
}); });
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => { it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
const inst = await freshDb(`ab-${status}.db`); const inst = await freshDb(`ab-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status }); await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status)); await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ab-unknown.db'); const inst = await freshDb('ab-unknown.db');
await expectActionError(abandonPackage(inst, 'nope'), 404); await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
}); });
}); });
describe('buyPlane', () => { describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db'); const inst = await freshDb('buy-ok.db');
await inst.db.update(schema.account).set({ cash: 500_000 }); await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const before = (await inst.db.select().from(schema.planes)).length; const before = (await inst.db.select().from(schema.planes)).length;
const { planeId } = await buyPlane(inst, 'atr72'); const { planeId } = await buyPlane(inst, 'atr72', 'local');
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const planes = await inst.db.select().from(schema.planes); const planes = await inst.db.select().from(schema.planes);
expect(planes.length).toBe(before + 1); expect(planes.length).toBe(before + 1);
@@ -556,44 +557,44 @@ describe('buyPlane', () => {
it('rejects an unknown catalog model with 400', async () => { it('rejects an unknown catalog model with 400', async () => {
const inst = await freshDb('buy-unknown.db'); const inst = await freshDb('buy-unknown.db');
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/); await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
}); });
it('rejects the purchase when cash is below the price with 409', async () => { it('rejects the purchase when cash is below the price with 409', async () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
}); });
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => { it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
const inst = await freshDb('buy-rep.db'); const inst = await freshDb('buy-rep.db');
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k. // rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 }); await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
const before = (await inst.db.select().from(schema.planes)).length; const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72'); await buyPlane(inst, 'atr72', 'local');
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT); const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100; const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6); expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1); 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 () => { 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'); const inst = await freshDb('buy-boundary.db');
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0. // rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 }); await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
const { planeId } = await buyPlane(inst, 'atr72'); const { planeId } = await buyPlane(inst, 'atr72', 'local');
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6); expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 }); await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i); await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
}); });
}); });
@@ -602,16 +603,16 @@ describe('sellPlane', () => {
const inst = await freshDb('sell-ok.db'); const inst = await freshDb('sell-ok.db');
// A second plane so the starter is not "the last remaining one". // A second plane so the starter is not "the last remaining one".
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { payout } = await sellPlane(inst, 'plane-extra'); const { payout } = await sellPlane(inst, 'plane-extra', 'local');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
const account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore + payout, 6); expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).not.toContain('plane-extra'); // row deleted expect(ids).not.toContain('plane-extra'); // row deleted
expect(ids).toContain('plane-starter'); expect(ids).toContain('plane-starter');
@@ -620,22 +621,22 @@ describe('sellPlane', () => {
it('rejects selling a flying plane with 409', async () => { it('rejects selling a flying plane with 409', async () => {
const inst = await freshDb('sell-flying.db'); const inst = await freshDb('sell-flying.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0, locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
}); });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
}); });
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => { it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
const inst = await freshDb('sell-cargo.db'); const inst = await freshDb('sell-cargo.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' }); await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
}); });
@@ -643,24 +644,24 @@ describe('sellPlane', () => {
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => { it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.db'); const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await queueLeg(inst, 'plane-extra', 'LEMD'); await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/); await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0]; const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
}); });
it('rejects selling the last remaining plane with 409', async () => { it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('sell-unknown.db'); const inst = await freshDb('sell-unknown.db');
await expectActionError(sellPlane(inst, 'ghost'), 404); await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
}); });
}); });
@@ -668,31 +669,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 () => { 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'); const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter'); const { cost } = await repairPlane(inst, 'plane-starter', 'local');
expect(cost).toBe(REPAIR_COST); expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing'); expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually… 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) 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 account = (await inst.db.select().from(schema.account))[0]; const player = await getLocalPlayer(inst);
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
}); });
it('is a free no-op when health is already full', async () => { it('is a free no-op when health is already full', async () => {
const inst = await freshDb('repair-full.db'); // seeded starter at 100 const inst = await freshDb('repair-full.db'); // seeded starter at 100
const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter'); const { cost } = await repairPlane(inst, 'plane-starter', 'local');
expect(cost).toBe(0); expect(cost).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // not grounded… expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100); expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
}); });
it('rejects a flying plane with 409', async () => { it('rejects a flying plane with 409', async () => {
@@ -701,7 +702,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
}); });
it('rejects a plane that is already repairing with 409', async () => { it('rejects a plane that is already repairing with 409', async () => {
@@ -710,35 +711,116 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
}); });
it('blocks load/fly/sell while repairing (the existing status guards)', async () => { it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
const inst = await freshDb('repair-blocks.db'); const inst = await freshDb('repair-blocks.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await repairPlane(inst, 'plane-starter'); await repairPlane(inst, 'plane-starter', 'local');
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
// A second plane so the "last remaining" guard cannot fire before the status one. // A second plane so the "last remaining" guard cannot fire before the status one.
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
}); });
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/); await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
}); });
it('rejects the repair when cash is below REPAIR_COST with 409', async () => { it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
const inst = await freshDb('repair-poor.db'); const inst = await freshDb('repair-poor.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 }); await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i); await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
}); });
it('rejects an unknown id with 404', async () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('repair-unknown.db'); const inst = await freshDb('repair-unknown.db');
await expectActionError(repairPlane(inst, 'ghost'), 404); await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
});
});
describe('ownership (v1.2.0, ISS-109)', () => {
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
async function addPlayerB(inst: GameDb): Promise<void> {
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
}
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
const inst = await freshDb('own-403.db');
await addPlayerB(inst);
await insertOpenPackage(inst, 'p-own');
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
// Load as the owner first so abandon has a bound package to check…
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
// Nothing was mutated by the rejected attempts.
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(JSON.parse(plane.queue)).toEqual([]);
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
});
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
const inst = await freshDb('own-403-rev.db');
await addPlayerB(inst);
await inst.db.insert(schema.planes).values({
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
});
it('enforces the last-plane guard per owner — not on the world total', async () => {
const inst = await freshDb('own-lastplane.db');
await addPlayerB(inst);
// 'b' owns two planes; 'local' only the starter. World total: three.
for (const id of ['plane-b1', 'plane-b2']) {
await inst.db.insert(schema.planes).values({
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
}
// Selling local's only plane is still forbidden even though the world has three planes…
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
// …while b can sell one of their two.
const cashBefore = (await getPlayer(inst, 'b')).cash;
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
});
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
const inst = await freshDb('own-buy.db');
await addPlayerB(inst);
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
const { planeId } = await buyPlane(inst, 'atr72', 'b');
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
expect(bought.ownerId).toBe('b');
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
}); });
}); });
+60 -36
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client'; import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -33,6 +35,7 @@ import { HUB_ICAO } from './seed';
// 404 — unknown entity id // 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane // 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane) // status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
// 400 — bad input that isn't an existence problem (unknown airport/model) // 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error { export class GameActionError extends Error {
@@ -52,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon }; return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
} }
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
}
// --- Load (POST /api/packages/:id/load {planeId}) --- // --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's // open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages // origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until // 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned). // delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> { export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([ const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)), inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)), inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
@@ -70,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
} }
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
} }
@@ -109,10 +118,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s // doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it // with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched. // 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> { export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
} }
@@ -140,11 +150,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// any mutation, so a 409 leaves fuel and cash untouched. // any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5)); const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel); const liters = Math.max(0, target - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) { if (cost > 0 && player.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
} }
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel). // Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
if (requiredL > target) { if (requiredL > target) {
@@ -173,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
}) })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
if (cost > 0) { if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
} }
await persist(inst); await persist(inst);
} }
@@ -185,10 +195,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// active leg still starts via flyPlane, and queued legs chain on landing when fuel // active leg still starts via flyPlane, and queued legs chain on landing when fuel
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO // covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// in the airport dataset. // in the airport dataset.
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> { export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) { if (!fc.features.some((f) => f.properties.icao === destIcao)) {
@@ -215,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are // Remove a NOT-yet-started leg from the queue by index. Only queued legs are
// cancellable — the active in-flight leg (destIcao) is never affected. Validation: // cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// known plane, integer index within [0, queue.length). // known plane, integer index within [0, queue.length).
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> { export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const queue = parseIcaoQueue(plane.queue); const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) { if (index < 0 || index >= queue.length) {
@@ -235,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
// scaling with time pressure (15% at creation → 60% at the deadline, see // scaling with time pressure (15% at creation → 60% at the deadline, see
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never // lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// goes below €0. The slot is freed immediately. // goes below €0. The slot is freed immediately.
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> { export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0]; const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`); if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
@@ -247,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0] ? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
: undefined; : undefined;
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`); if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`); throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
} }
@@ -255,9 +268,9 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs); const penalty = abandonPenalty(pkg, sim.clockMs);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) }); await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId)); await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst); await persist(inst);
return { penalty }; return { penalty };
@@ -268,16 +281,16 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with // Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank // reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF). // at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> { export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model]; const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction. // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100; const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (account.cash < cost) { if (player.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
} }
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
@@ -287,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const planeId = randomUUID(); const planeId = randomUUID();
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: planeId, id: planeId,
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
model, model,
status: 'available', status: 'available',
fuel: type.maxFuel, // full tank on delivery fuel: type.maxFuel, // full tank on delivery
@@ -298,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null, departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069) health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
}); });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
await persist(inst); await persist(inst);
return { planeId }; return { planeId };
} }
@@ -307,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last // Sell a grounded plane for SELL_FRACTION of its catalog price. The last
// remaining plane can never be sold — the company needs at least one aircraft. // remaining plane can never be sold — the company needs at least one aircraft.
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> { export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
} }
@@ -329,17 +344,18 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first'); throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
} }
const all = await inst.db.select().from(schema.planes); // v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); // players' planes exist in the world, and selling one of two of yours is fine.
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
const type = PLANE_TYPES[plane.model]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100; const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId)); await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const account = (await inst.db.select().from(schema.account))[0]; const player = await getPlayer(inst, playerId);
if (!account) throw new Error('account row missing'); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
await persist(inst); await persist(inst);
return { payout }; return { payout };
} }
@@ -352,10 +368,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED // and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown // planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds. // plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
} }
@@ -364,17 +381,17 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const account = (await inst.db.select().from(schema.account))[0]; // v1.2.0 (ISS-109): the owner's own economy pays for the repair.
if (!account) throw new Error('account row missing'); const player = await getPlayer(inst, playerId);
if (account.cash < REPAIR_COST) { if (player.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
} }
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate. // Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 }); await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await persist(inst); await persist(inst);
return { cost: REPAIR_COST }; return { cost: REPAIR_COST };
} }
@@ -385,10 +402,17 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
// game time — then respond with the FULL current state snapshot (same shape as // game time — then respond with the FULL current state snapshot (same shape as
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to // GET /api/state), so clients never need a follow-up poll. GameActionError maps to
// its own status + {error}; anything unexpected is a 500. // its own status + {error}; anything unexpected is a 500.
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> { // v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try { try {
await runAction(fn); await runAction((inst) => fn(inst, auth.player.id));
return Response.json(await getStateSnapshot()); // v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`.
return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) { } catch (err) {
if (err instanceof GameActionError) { if (err instanceof GameActionError) {
return Response.json({ error: err.message }, { status: err.status }); return Response.json({ error: err.message }, { status: err.status });
+18 -11
View File
@@ -360,19 +360,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0]; return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
} }
export function buildSeed(input: { // v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
airports: SeedAirport[]; // it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
rng: Rng; // caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
nowMs: number; export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const type = PLANE_TYPES[STARTER_MODEL]; const type = PLANE_TYPES[STARTER_MODEL];
const hub = pickHub(airports); const hub = pickHub(airports);
return {
const plane: SeedPlane = { id,
id: 'plane-starter',
model: STARTER_MODEL, model: STARTER_MODEL,
status: 'available', status: 'available',
fuel: type.maxFuel, // starts full fuel: type.maxFuel, // starts full
@@ -384,6 +379,18 @@ export function buildSeed(input: {
queue: [], // no queued legs on first run (v0.25.0) queue: [], // no queued legs on first run (v0.25.0)
health: 100, // full health on first run (v0.39.0, ISS-069) health: 100, // full health on first run (v0.39.0, ISS-069)
}; };
}
export function buildSeed(input: {
airports: SeedAirport[];
rng: Rng;
nowMs: number;
}): SeedData {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('seed needs at least two airports');
const plane = buildStarterPlane(airports, 'plane-starter');
const hub = pickHub(airports);
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport // v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick. // composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
+11 -7
View File
@@ -2,6 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { getPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
@@ -113,20 +114,23 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) }; return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
} }
export async function getStateSnapshot(): Promise<StateSnapshot> { // v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
const { db } = await getGameDb(); // shape as before ({cash, reputation}), sourced per-player. Callers without a session context
// (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112).
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
const inst = await getGameDb();
const { db } = inst;
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm // NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and // 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding). // returns raw rows keyed by SQL column names (snake_case, no type decoding).
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([ const [player, simRows, planeRows, packageRows] = await Promise.all([
db.select().from(schema.account), getPlayer(inst, playerId),
db.select().from(schema.simState), db.select().from(schema.simState),
db.select().from(schema.planes), db.select().from(schema.planes),
db.select().from(schema.packages), db.select().from(schema.packages),
]); ]);
const accountRow = accountRows[0];
const simRow = simRows[0]; const simRow = simRows[0];
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row'); if (!simRow) throw new Error('game state missing sim_state row');
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
@@ -169,7 +173,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
}; };
return { return {
account: { cash: accountRow.cash, reputation: accountRow.reputation }, account: { cash: player.cash, reputation: player.reputation },
simState: { clockMs: simRow.clockMs }, simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(), catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L, fuelPricePerL: FUEL_PRICE_PER_L,
+65 -9
View File
@@ -3,18 +3,24 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => { it('documents exactly the eighteen public endpoints (4 read + 8 player actions + 4 auth + 2 admin CRUD)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/admin/tables', // v0.71.0 — admin panel
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/login', // v0.71.0 — admin panel auth
'/api/logout', // v0.71.0
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/register', // v1.1.0 (ISS-107) — self-service account creation
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
@@ -179,16 +185,17 @@ describe('OpenAPI spec', () => {
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot'); expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
} }
// Error shapes per endpoint. // Error shapes per endpoint. v1.2.0 (ISS-109): every plane-targeted action also documents 403
// (the session player does not own the plane); buy-plane has no plane target, so it gains none.
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort(); const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '403', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0 expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '403', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400 expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['403', '404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none. // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
@@ -242,4 +249,53 @@ describe('OpenAPI spec', () => {
expect(sell.required).toEqual(['planeId']); expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string'); expect(sell.properties.planeId.type).toBe('string');
}); });
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
}
// Success shapes.
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
expect(tables.type).toBe('array');
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
// Request bodies + path parameter.
const bodyRef = (p: string, m: string) =>
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
?.schema.$ref;
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
}
// Request schema shapes.
const login = openApiSpec.components.schemas.LoginRequest;
expect(login.required).toEqual(['username', 'password']);
expect(login.properties.username.type).toBe('string');
expect(login.properties.password.type).toBe('string');
const sessionUser = openApiSpec.components.schemas.SessionUser;
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
expect(sessionUser.properties.admin.type).toBe('boolean');
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
expect(updateRow.required).toEqual(['rowid', 'values']);
expect(updateRow.properties.rowid.type).toBe('integer');
});
}); });
+392 -1
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'Flight Ops API', title: 'Flight Ops API',
description: description:
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.', 'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
// Sourced from package.json so it stays in sync with every version bump. // Sourced from package.json so it stays in sync with every version bump.
version: pkg.version, version: pkg.version,
}, },
@@ -110,6 +110,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown package id or plane id', description: 'Unknown package id or plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -136,6 +140,10 @@ export const openApiSpec = {
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)', description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
403: {
description: 'The session player does not own the bound plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown package id', description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -174,6 +182,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset', 'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -213,6 +225,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset', 'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -252,6 +268,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)', 'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -312,6 +332,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -337,6 +361,10 @@ export const openApiSpec = {
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)', description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: { 404: {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -348,6 +376,274 @@ 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: { components: {
schemas: { schemas: {
@@ -496,6 +792,101 @@ export const openApiSpec = {
}, },
required: ['planeId'], required: ['planeId'],
}, },
// v1.1.0 — multiplayer step 3 (ISS-107).
RegisterRequest: {
type: 'object',
properties: {
username: { type: 'string', description: '3–20 chars of [a-z0-9_-]; input is lowercased before validation.' },
password: { type: 'string', description: 'Plaintext, at least 8 chars — stored as a scrypt digest server-side.' },
},
required: ['username', 'password'],
},
// v0.71.0 — admin panel auth + DB tooling schemas.
LoginRequest: {
type: 'object',
properties: {
username: { type: 'string', description: 'Account username (non-empty).' },
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
},
required: ['username', 'password'],
},
SessionUser: {
type: 'object',
properties: {
username: { type: 'string' },
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
},
required: ['username', 'admin'],
},
LoginResponse: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
user: { $ref: '#/components/schemas/SessionUser' },
},
required: ['sessionId', 'user'],
},
OkResponse: {
type: 'object',
properties: {
ok: { const: true },
},
required: ['ok'],
},
AdminTable: {
type: 'object',
properties: {
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
},
required: ['name'],
},
AdminRowsResponse: {
type: 'object',
properties: {
columns: {
type: 'array',
items: { type: 'string' },
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
},
rows: {
type: 'array',
items: { type: 'object', additionalProperties: true },
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
},
},
required: ['columns', 'rows'],
},
UpdateRowRequest: {
type: 'object',
properties: {
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
values: {
type: 'object',
additionalProperties: true,
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
},
},
required: ['rowid', 'values'],
},
InsertRowRequest: {
type: 'object',
properties: {
values: {
type: 'object',
additionalProperties: true,
description: 'Exactly the columns to set; others take their defaults or NULL.',
},
},
required: ['values'],
},
InsertRowResponse: {
type: 'object',
properties: {
ok: { const: true },
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
},
required: ['ok', 'rowid'],
},
Error: { Error: {
type: 'object', type: 'object',
properties: { properties: {
+103 -30
View File
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
@@ -228,20 +229,20 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
it('does nothing before the arrival time', async () => { it('does nothing before the arrival time', async () => {
const { inst, km, type } = await setupFlight('deliver-early.db'); const { inst, km, type } = await setupFlight('deliver-early.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
expect(await deliverPackages(inst, clock)).toBe(0); expect(await deliverPackages(inst, clock)).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
expect(account.cash).toBe(accountBefore.cash); // no award before landing expect(player.cash).toBe(playerBefore.cash); // no award before landing
}); });
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { 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 { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
// Three packages on the plane: one in-deadline for TO, one expired for TO, // 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). // one loaded for a DIFFERENT airport (stays bound for a later leg).
@@ -274,23 +275,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')!.status).toBe('loaded'); // not this airport's cargo…
expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // ONLY p-pay paid out
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km)) * 100) / 100,
9, 9,
); );
// A later tick must not award again — the plane is no longer 'flying'. // A later tick must not award again — the plane is no longer 'flying'.
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0); expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
const accountAfter = (await inst.db.select().from(schema.account))[0]!; const playerAfter = (await getLocalPlayer(inst));
expect(accountAfter.cash).toBe(account.cash); expect(playerAfter.cash).toBe(player.cash);
expect(accountAfter.reputation).toBe(account.reputation); expect(playerAfter.reputation).toBe(player.reputation);
}); });
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => { it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
const { inst, km } = await setupFlight('deliver-multi.db'); const { inst, km } = await setupFlight('deliver-multi.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
await inst.db.insert(schema.packages).values([ 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 }, { 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 },
@@ -300,17 +301,17 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards expect(player.cash).toBeCloseTo(playerBefore.cash + 3_500, 9); // both rewards
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
9, 9,
); // rep per delivered package ); // rep per delivered package
}); });
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => { 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 { inst, km } = await setupFlight('deliver-special.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const playerBefore = (await getLocalPlayer(inst));
await inst.db.insert(schema.packages).values([ 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 }, { 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 },
@@ -319,11 +320,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award. // Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo( expect(player.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100, Math.round((playerBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9, 9,
); );
}); });
@@ -381,7 +382,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
@@ -402,7 +403,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel); const liters = Math.max(0, target - landedFuel);
expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left
const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const rate = repRate(playerBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
@@ -412,21 +413,21 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order 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 expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level
const account = (await inst.db.select().from(schema.account))[0]!; const player = (await getLocalPlayer(inst));
expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost expect(player.cash).toBeCloseTo(playerBefore.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 () => { 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'); const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db');
// Same empty-tank landing as above, but the account is broke — the top-up is unaffordable. // Same empty-tank landing as above, but the player is broke — the top-up is unaffordable.
const w = weatherFor(FROM); const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km); const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 0 }); await updateLocalPlayerEconomy(inst, { cash: 0 });
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
@@ -438,7 +439,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); 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(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched
expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0); expect((await getLocalPlayer(inst)).cash).toBe(0);
}); });
it('grounds as before when the queue is empty', async () => { it('grounds as before when the queue is empty', async () => {
@@ -496,6 +497,78 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.destIcao).toBeNull(); expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
}); });
// --- Per-owner economy (v1.3.0, ISS-110) ---
it('pays cash + reputation to the plane OWNER, not the local player', async () => {
const { inst, km } = await setupFlight('deliver-owner.db');
// A second player owns the starter — distinct economy so any leak into 'local' is visible.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 5 });
await inst.db.update(schema.planes)
.set({ ownerId: 'player-b' })
.where(eq(schema.planes.id, 'plane-starter'));
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
const bBefore = (await getPlayer(inst, 'player-b'));
await inst.db.insert(schema.packages).values([
{ id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = 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)', () => { describe('wear on landed legs (v0.39.0, ISS-069)', () => {
@@ -782,7 +855,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) }) .set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null, id: 'p-2', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
}); });
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0); const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
@@ -827,7 +900,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it // grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
// never lands here. // never lands here.
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300, id: 'p-air', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS, locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
}); });
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
+19 -13
View File
@@ -1,6 +1,7 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import { LOCAL_PLAYER, getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -250,13 +251,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
for (const pk of onBoard) { for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) { if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id)); await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
// Fresh account read per award so multiple deliveries in one tick all count. // v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
const fresh = (await inst.db.select().from(schema.account))[0]; // multiple deliveries in one tick all count.
if (!fresh) throw new Error('account row missing'); const fresh = await getPlayer(inst, p.ownerId);
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the // v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded). // distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0; const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await inst.db.update(schema.account).set({ await updatePlayerEconomy(inst, p.ownerId, {
cash: fresh.cash + pk.reward, cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100, reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
}); });
@@ -306,15 +307,15 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel); const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. // v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
const fresh = (await inst.db.select().from(schema.account))[0]; // delivery awards above) so a top-up in this tick sees current cash.
if (!fresh) throw new Error('account row missing'); const fresh = await getPlayer(inst, p.ownerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) { if (cost === 0 || fresh.cash >= cost) {
queue.shift(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
} }
} }
} }
@@ -383,9 +384,11 @@ export async function spawnPoolPackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch. // v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
const account = (await inst.db.select().from(schema.account))[0]; // ponytail: stays on the seeded 'local' row in v1.3.0 (ISS-110) — pools are SHARED world state
if (!account) throw new Error('account row missing'); // (packages have no owner), so there is no single owning player whose rep should set job quality;
// per-player job quality / pools is a later multiplayer step (#112).
const player = await getLocalPlayer(inst);
let n = 0; let n = 0;
for (const originIcao of pool) { for (const originIcao of pool) {
@@ -415,7 +418,7 @@ export async function spawnPoolPackages(
clockMs, clockMs,
id: randomUUID(), id: randomUUID(),
createdRealMs: real, createdRealMs: real,
reputation: account.reputation, reputation: player.reputation,
}), }),
); );
openByBand[bandIdx]++; openByBand[bandIdx]++;
@@ -433,9 +436,12 @@ export async function spawnPoolPackages(
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged, // package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for // not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
// other clients too. // other clients too.
// v1.3.0 (ISS-110): the shared broadcast channel has no acting session — it carries the seeded
// local player's account until per-player SSE frames land (#112). Session-scoped reads (/api/state,
// /api/events connect frame, action responses) all source their own row.
async function broadcastState(): Promise<void> { async function broadcastState(): Promise<void> {
try { try {
broadcast(JSON.stringify(sseFrame(await getStateSnapshot()))); broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id))));
} catch (err) { } catch (err) {
console.error('flight: state broadcast failed', err); console.error('flight: state broadcast failed', err);
} }
+3 -1
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { StateSnapshot } from '@/lib/game/state'; import type { StateSnapshot } from '@/lib/game/state';
import { sessionHeaders } from './authClient';
// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from // ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from
// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint; // RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint;
@@ -30,7 +31,8 @@ export function useGameAction(
try { try {
const res = await fetch(path, { const res = await fetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, // v1.1.0 (ISS-107): action endpoints are session-guarded — echo the stored id.
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store', cache: 'no-store',
}); });
+13 -1
View File
@@ -22,7 +22,19 @@ describe('fetchState', () => {
await expect(fetchState()).resolves.toEqual(SNAPSHOT); await expect(fetchState()).resolves.toEqual(SNAPSHOT);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' }); // No stored session in node → empty headers object (v1.1.0, ISS-107).
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: {} });
});
it('sends the stored session as X-Flight-Session when one exists (v1.1.0)', async () => {
vi.stubGlobal('window', {
localStorage: { getItem: (k: string) => (k === 'flight.session' ? 'sid-123' : null), removeItem: () => {} },
});
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT }));
vi.stubGlobal('fetch', fetchMock);
await fetchState();
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: { 'X-Flight-Session': 'sid-123' } });
}); });
it('throws with the HTTP status on a non-2xx response', async () => { it('throws with the HTTP status on a non-2xx response', async () => {
+7 -2
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state'; import type { StateSnapshot } from './game/state';
import { sessionHeaders, storedSession } from './authClient';
export interface GameStateResult { export interface GameStateResult {
state: StateSnapshot | null; state: StateSnapshot | null;
@@ -19,7 +20,8 @@ export interface GameStateResult {
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch. // Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
export async function fetchState(): Promise<StateSnapshot> { export async function fetchState(): Promise<StateSnapshot> {
const res = await fetch('/api/state', { cache: 'no-store' }); // v1.1.0 (ISS-107): /api/state is session-guarded — the stored id rides along as X-Flight-Session.
const res = await fetch('/api/state', { cache: 'no-store', headers: sessionHeaders() });
if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`); if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`);
const snapshot = (await res.json()) as StateSnapshot; const snapshot = (await res.json()) as StateSnapshot;
// Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what // Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what
@@ -93,7 +95,10 @@ export function useGameState(intervalMs = 1000): GameStateResult {
let source: EventSource | null = null; let source: EventSource | null = null;
try { try {
source = new EventSource(SSE_URL); // v1.1.0 (ISS-107): EventSource cannot set custom headers, so the session id rides along as a
// query param — /api/events accepts ?session= exactly for this (requireAuthSse).
const sid = storedSession();
source = new EventSource(sid ? `${SSE_URL}?session=${encodeURIComponent(sid)}` : SSE_URL);
} catch { } catch {
source = null; // no EventSource support — pure polling below source = null; // no EventSource support — pure polling below
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.70.0", "version": "1.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",