Compare commits

..
12 Commits
Author SHA1 Message Date
kamilkrzywda d2b799e572 Merge pull request 'v1.8.0: e2e speedup — per-test timescale rates (FAST/CHAIN)' (#162) from feat/e2e-speedup into master
CI / build + unit + e2e (push) Successful in 6m0s
2026-09-27 22:34:27 +02:00
kamilkrzywda 664906b8ce e2e: per-test timescale speedup — FAST/CHAIN rates for pure arrival waits (v1.8.0)
CI / build + unit + e2e (pull_request) Successful in 6m3s
- actions/hover/two-planes: FAST=10_000 for expiry fast-forwards, landing
  waits and fly-home cleanups; queue: + CHAIN=4_000 for auto-chain
  observation, refuel test at explicit 2_000 with relaxed fuel bound
- map-interactions: FAST=50_000 chain hops (~3 min -> ~15 s per direction);
  crossing leg stays at SCALE and now sets it BEFORE dispatch (a leftover
  fast rate would let the dispatch tick consume most of the leg)
- hover fleet-row test asserts flying from the fly endpoint's own snapshot
  and freezes immediately instead of speeding up then freezing mid-leg
- two-planes parallel-flight: both-grounded poll after cleanup dispatches so
  afterEach heal no longer pays the range-edge return leg at SCALE
2026-09-27 20:18:56 +00:00
kamilkrzywda b60006a949 Merge pull request 'v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)' (#161) from feat/multiplayer-test-coverage into master
CI / build + unit + e2e (push) Successful in 13m51s
2026-09-26 22:40:02 +02:00
kamilkrzywda fe84d9a0bb v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)
CI / build + unit + e2e (pull_request) Successful in 13m33s
2026-09-26 20:26:10 +00:00
kamilkrzywda 43c4bb97a9 Merge pull request 'v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115)' (#160) from feat/multiplayer-leaderboard into master
CI / build + unit + e2e (push) Successful in 14m16s
2026-09-26 21:52:36 +02:00
kamilkrzywda b04e504263 v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115)
CI / build + unit + e2e (pull_request) Successful in 14m2s
2026-09-26 19:38:25 +00:00
kamilkrzywda d87d848ca2 Merge pull request 'v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)' (#156) from feat/multiplayer-spawn-quality into master
CI / build + unit + e2e (push) Successful in 13m34s
2026-09-26 21:21:52 +02:00
kamilkrzywda eae680c002 v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)
CI / build + unit + e2e (pull_request) Successful in 13m53s
2026-09-26 19:07:50 +00:00
kamilkrzywda c3fa5bf613 Merge pull request 'v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)' (#155) from feat/multiplayer-snapshot-market into master
CI / build + unit + e2e (push) Successful in 14m12s
2026-09-26 20:51:48 +02:00
kamilkrzywda fe60bd0482 v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)
CI / build + unit + e2e (pull_request) Successful in 14m29s
2026-09-26 18:37:11 +00:00
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
40 changed files with 1214 additions and 178 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## v1.8.0 — 2026-09-27 20:13
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
## v1.7.0 — 2026-09-26 20:24
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
## v1.6.0 — 2026-09-26 19:35
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
## v1.5.0 — 2026-09-26 19:06
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
## v1.4.0 — 2026-09-26 18:35
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
## v1.3.0 — 2026-09-26 17:05
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths.
## v1.2.0 — 2026-09-26 16:25 ## 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 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. - **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.
+59 -1
View File
@@ -2,8 +2,11 @@ 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 { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { getPlayer } from '@/lib/db/localPlayer';
import { tickNow } from '@/lib/sim/engine';
import { GET } from './route'; import { GET } from './route';
let dir: string; let dir: string;
@@ -49,8 +52,63 @@ describe('GET /api/events', () => {
expect(text.startsWith('data: ')).toBe(true); expect(text.startsWith('data: ')).toBe(true);
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>; const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number'); expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
const planes = snap.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['ownerId', 'ownerName']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key);
}
const pkgs = snap.packages as Array<Record<string, unknown>>;
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
} finally { } finally {
await reader.cancel(); // → stream cancel → unsubscribe from the registry await reader.cancel(); // → stream cancel → unsubscribe from the registry
} }
}); });
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// A second player with a distinct economy + their own session (rows minted like /api/register).
const idB = 'test-player-events-b';
await inst.db.insert(schema.users).values({
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
});
const sidB = await createSession(inst, idB);
const localCash = (await getPlayer(inst, 'local')).cash;
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
// Reads the next `data:` event off a stream and parses its snapshot.
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
const { value } = await reader.read();
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
};
try {
// Drain both connect frames (each already carries its own account row)…
const connectA = await readFrame(readerA);
const connectB = await readFrame(readerB);
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
await tickNow();
const pushA = await readFrame(readerA);
const pushB = await readFrame(readerB);
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
} finally {
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
await readerB.cancel();
}
});
}); });
+9 -4
View File
@@ -10,7 +10,10 @@ import { subscribe } from '@/lib/sse';
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session= // 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 // (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 // 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). // be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
// player, pushing each frame only to its group; connect + pushed frames share one projection
// (getStateSnapshot + sseFrame), so they can never drift.
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000; const HEARTBEAT_MS = 15_000;
@@ -30,10 +33,12 @@ export async function GET(req: Request) {
start(controller) { start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so // Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s // events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
// for the next push instead of seeing an out-of-order frame. // for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
getStateSnapshot() // every later push carry THIS session's player projection — subscribe keys on auth.player.id so
// the tick broadcast only sends this client frames projected for its own account row.
getStateSnapshot(auth.player.id)
.then((snapshot) => { .then((snapshot) => {
unsubscribe = subscribe((text) => { unsubscribe = subscribe(auth.player.id, (text) => {
try { try {
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns // broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic). // wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
+1
View File
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
@@ -102,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
+75 -5
View File
@@ -2,10 +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 { eq } from 'drizzle-orm';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession } from '@/lib/auth'; import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { GET } from './route'; import { GET } from './route';
@@ -44,6 +46,7 @@ describe('GET /api/state', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
@@ -57,8 +60,8 @@ describe('GET /api/state', () => {
const planes = body.planes as Array<Record<string, unknown>>; const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1); expect(planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) { for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
expect(planes[0], `plane.${key}`).toHaveProperty(key); expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
} }
expect(typeof planes[0].locationName).toBe('string'); expect(typeof planes[0].locationName).toBe('string');
// The seeded starter is grounded — no flight destination yet. // The seeded starter is grounded — no flight destination yet.
@@ -67,8 +70,8 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>; const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0); expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
expect(packages[0], `package.${key}`).toHaveProperty(key); expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
} }
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the // v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
@@ -113,4 +116,71 @@ describe('GET /api/state', () => {
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L); expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION); expect(snap.sellFraction).toBe(SELL_FRACTION);
}); });
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy — rows minted like /api/register does.
const id = 'test-player-b';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
});
const sid = await createSession(inst, id);
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
expect(res.status).toBe(200);
const body = (await res.json()) as { account: { cash: number; reputation: number } };
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
});
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// A second player with a plane of their own — rows minted like /api/register does.
const id = 'test-player-c';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
});
const type = PLANE_TYPES.atr72;
await inst.db.insert(schema.planes).values({
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
});
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
expect(res.status).toBe(200);
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
const starter = body.planes.find((p) => p.id === 'plane-starter');
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
expect(foreign?.ownerId).toBe(id);
expect(foreign?.ownerName).toBe('e2e-owner-c');
});
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
const inst = await getGameDb();
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
const snapBefore = await before;
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId: 'plane-test-c' })
.where(eq(schema.packages.id, String(openPkg.id)));
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
const after = await snap;
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
for (const pkg of after.packages) { // …and every unassigned package stays null.
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
}
});
}); });
+5 -3
View File
@@ -2,8 +2,10 @@ 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';
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is // v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
// unchanged; per-player snapshots are a later step. // v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
// v1.4.0 (ISS-112): full shared-world snapshot — ALL planes with owner info, packages with
// carrierName; same projection as the SSE frames, so poll and push can never drift.
export async function GET(req: Request) { export async function GET(req: Request) {
const auth = await requireAuth(req); const auth = await requireAuth(req);
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
@@ -11,7 +13,7 @@ export async function GET(req: Request) {
// 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 });
+4 -2
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -46,10 +46,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
// player the e2e suite plays via mint-player-session's default).
const player = await getLocalPlayer(inst); const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 }); await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+5 -5
View File
@@ -1,6 +1,6 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getSharedWorldReputation } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed'; import { generatePackage, mulberry32 } from '@/lib/game/seed';
@@ -83,9 +83,9 @@ export async function POST(req: Request) {
} }
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality // v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
// as engine spawns; never force a fixed value here. // rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
const player = await getLocalPlayer(inst); const reputation = await getSharedWorldReputation(inst);
const created = generatePackage({ const created = generatePackage({
id: randomUUID(), id: randomUUID(),
airports, airports,
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
weightKg: input.weightKg as number | undefined, weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null), special: input.special === undefined ? null : (input.special as string | null),
reputation: player.reputation, reputation,
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+69
View File
@@ -0,0 +1,69 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
// same gate, same conventions.
//
// POST /api/test/delete-plane
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = input.username;
if (typeof username !== 'string' || username.length === 0) {
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
let deleted = 0;
if (players[0]) {
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
for (const plane of owned) {
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
deleted++;
}
}
if (deleted > 0) {
await persist(inst);
}
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+4 -2
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client'; import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state'; import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine'; import { tickNow } from '@/lib/sim/engine';
@@ -42,10 +42,12 @@ export async function POST(req: Request) {
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings) await tickNow(); // clock is current before the snapshot we return (same as siblings)
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
// the e2e suite plays via mint-player-session's default).
await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone
await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 }); await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 });
await persist(inst); await persist(inst);
return Response.json(await getStateSnapshot()); return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) { } catch (err) {
console.error('test hook failed', err); console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 }); return Response.json({ error: 'test hook failed' }, { status: 500 });
+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 });
+35
View File
@@ -244,6 +244,15 @@ body {
color: #bae6fd; color: #bae6fd;
} }
/* v1.4.0 (ISS-112): owner / carrier badge — whose plane or cargo this is in the shared world. */
.owner-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #bbf7d0;
}
.fuel-bar { .fuel-bar {
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
@@ -728,6 +737,32 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
.leaderboard {
padding: 14px;
overflow-y: auto;
}
.leaderboard table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.leaderboard th,
.leaderboard td {
padding: 4px 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.leaderboard th {
color: #94a3b8;
font-weight: 600;
}
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */ /* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
.sign-in { .sign-in {
position: fixed; position: fixed;
+36
View File
@@ -0,0 +1,36 @@
import { formatCash } from '@/lib/format';
import type { PlayerSummary } from '@/lib/game/state';
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
// reputation desc; cash then username break ties deterministically so rows don't shuffle
// between snapshots when two players share a reputation.
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
const sorted = [...players].sort(
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
);
return (
<div className="leaderboard">
<table data-testid="leaderboard-table">
<thead>
<tr>
<th>Player</th>
<th>Cash</th>
<th>Reputation</th>
<th>Planes</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => (
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
<td>{p.username}</td>
<td>{formatCash(p.cash)}</td>
<td>{p.reputation}</td>
<td>{p.planeCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+4
View File
@@ -157,6 +157,10 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
> >
<div className="plane-main"> <div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span> <span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
{p.ownerName}
</span>
{p.status === 'flying' ? ( {p.status === 'flying' ? (
<> <>
<span className="plane-status status-flying">in flight</span> <span className="plane-status status-flying">in flight</span>
+11
View File
@@ -13,6 +13,7 @@ import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime'; import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal'; import Modal from './Modal';
import AdminPanel from './AdminPanel'; import AdminPanel from './AdminPanel';
import Leaderboard from './Leaderboard';
import { sessionHeaders } from '../lib/authClient'; 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
@@ -98,6 +99,8 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the // v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
// stored session on every open. // stored session on every open.
const [adminOpen, setAdminOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false);
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
// Push the latest planes/routes GeoJSON into whatever sources currently exist. // Push the latest planes/routes GeoJSON into whatever sources currently exist.
// No-op until the map (and for routes: the airport index) is ready — the mount // No-op until the map (and for routes: the airport index) is ready — the mount
@@ -589,6 +592,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
<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>
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
Leaderboard
</button>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */} {/* 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)}> <button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin Admin
@@ -598,6 +605,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin"> <Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel onLogout={onLogout} /> <AdminPanel onLogout={onLogout} />
</Modal> </Modal>
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
<Leaderboard players={state?.players ?? []} />
</Modal>
</div> </div>
); );
} }
+6
View File
@@ -209,6 +209,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{formatSpecialLabel(pkg.special)} {formatSpecialLabel(pkg.special)}
</span> </span>
)} )}
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
{pkg.carrierName && (
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
{pkg.carrierName}
</span>
)}
{eligible.length > 0 ? ( {eligible.length > 0 ? (
<LoadControls <LoadControls
pkg={pkg} pkg={pkg}
+4
View File
@@ -27,6 +27,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
health: 100, // v0.39.0 (ISS-069) — full-health fixture default health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null, repairUntilSimTime: null,
queue: [], queue: [],
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
ownerName: 'kamilkrzywda',
...over, ...over,
}; };
} }
@@ -48,6 +50,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
...over, ...over,
}; };
} }
@@ -57,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes, planes,
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
packages, packages,
catalog: CATALOG, catalog: CATALOG,
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
+6 -5
View File
@@ -20,6 +20,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions. // penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
@@ -334,7 +335,7 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
}); });
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page); await flyHomeViaApi(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must // A: load, let it expire while loaded, then FLY to its destination — arrival must
@@ -350,12 +351,12 @@ test('expired packages deliver and abandon with no reward and no penalty', async
} }
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry // A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why // window so scale 10_000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it. // the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page); await activateWorld(page);
try { try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE); await setScale(page, FAST);
let s = await getState(page); let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired'); await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page); s = await getState(page);
@@ -387,9 +388,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: top up + fly home at SCALE. // Restore: top up + fly home at FAST (pure arrival wait).
await setFuelFull(page, 'plane-starter'); await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE); await setScale(page, FAST);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy(); expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
+5 -2
View File
@@ -9,8 +9,11 @@ import { expect } from '@playwright/test';
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
export async function mintPlayerSession(page: Page): Promise<string> { // v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
const res = await page.request.post('/api/test/mint-player-session'); // supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
// usual local player, exactly as before.
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200); 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; return ((await res.json()) as { sessionId: string }).sessionId;
} }
+23 -17
View File
@@ -17,6 +17,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). // additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
@@ -516,7 +517,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}); });
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => { test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -525,23 +526,26 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up. // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
// post-action snapshot already shows the plane flying — no poll race.
const pkg = await createPackage(page, HUB, DEST); const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200); expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); const flyBody = (await fly.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
// test below).
await setScale(page, 1);
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054). // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('plane-plane-starter'); const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
// e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
// the route line stays drawn while frozen.
await setScale(page, 1);
await block.hover(); await block.hover();
await expect.poll(async () => { await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>; const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
@@ -554,9 +558,10 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
message: 'waiting for the highlight to clear after mouseleave', message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0); }).toBe(0);
// Resume and fast-forward to arrival, then fly home so the live instance stays in its // Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
// onboarding state (starter grounded at EDDF) for the other specs. // stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
await setScale(page, SCALE); // arrivals → FAST (~1 s each instead of ~10 s).
await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -564,8 +569,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true); .toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect await expect
@@ -577,7 +582,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
}); });
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => { test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
@@ -651,7 +656,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
// not DEST — it never delivered), then fly home. // not DEST — it never delivered), then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200); expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -662,8 +668,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`); const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
// Re-set SCALE for the return leg. // Re-set FAST for the return leg (~1 s instead of ~10 s).
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect await expect
+21 -15
View File
@@ -20,6 +20,9 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// the 6300 L tank, so a full-tank departure always makes it home. // the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD'; const DEST = 'LEMD';
const SCALE = 1000; // test rate for fast-forwarding legs const SCALE = 1000; // test rate for fast-forwarding legs
// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish
// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate.
const FAST = 50_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface PlaneState { interface PlaneState {
@@ -106,15 +109,15 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Load a package onto plane-starter and fly it from → to via the API, then poll at // Load a package onto plane-starter and fly it from → to via the API, then fast-forward at FAST
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts. // (pure arrival wait — no mid-leg observation) until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> { async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const pkg = await createPackage(page, fromIcao, toIcao); const pkg = await createPackage(page, fromIcao, toIcao);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200); expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST);
await expect await expect
.poll(async () => { .poll(async () => {
const body = await getState(page); const body = await getState(page);
@@ -204,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
}); });
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => { test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -215,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route',
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter'); const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
expect(foundStarter).toBeDefined(); expect(foundStarter).toBeDefined();
if (foundStarter!.status === 'flying') { if (foundStarter!.status === 'flying') {
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
await expect await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
timeout: 240_000, timeout: 240_000,
@@ -348,8 +351,8 @@ test('plane markers move while flying and the plane popup shows status + route',
// The popup's route line is the plane's own leg: origin name → destination name. // The popup's route line is the plane's own leg: origin name → destination name.
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`); await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Restore SCALE before step 5 — the landing wait needs a fast clock. // Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s.
await setScale(page, SCALE); await setScale(page, FAST);
// 5) Let the flight finish, then fly home so the live instance stays in its // 5) Let the flight finish, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. The loaded // onboarding state (starter grounded at EDDF) for the other specs. The loaded
@@ -478,7 +481,7 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
} }
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -489,7 +492,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain. // Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') { if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
} }
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end // Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
@@ -500,7 +503,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
for (const next of await chainTo(page, loc, ORIGIN)) { for (const next of await chainTo(page, loc, ORIGIN)) {
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
} }
} }
@@ -521,9 +524,12 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
const pkg = await createPackage(page, ORIGIN, CROSS_DEST); const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
// The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the
// >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST
// rate would let the dispatch tick itself consume most of the leg before this setScale lands.
await setScale(page, SCALE);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`); if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`);
await setScale(page, SCALE);
console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this // querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
@@ -591,16 +597,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180 expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180
expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window
} finally { } finally {
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical // Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other
// state. The package was delivered on arrival (generous deadline). // specs start from the canonical state. The package was delivered on arrival (generous deadline).
await setScale(page, SCALE); await setScale(page, FAST);
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (endLoc !== HUB) { if (endLoc !== HUB) {
for (const next of await chainTo(page, endLoc, HUB)) { for (const next of await chainTo(page, endLoc, HUB)) {
await setFuelFull(page, 'plane-starter'); await setFuelFull(page, 'plane-starter');
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
await setScale(page, SCALE); await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
} }
} }
+283
View File
@@ -0,0 +1,283 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { applySession, authenticate, mintPlayerSession } from './auth';
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
// player; player B is a second account minted through the same hook with a username override (the
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
//
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
interface PlaneState {
id: string;
status: string;
locationIcao: string;
maxFuel: number;
queue: string[];
ownerId: string;
ownerName: string;
}
interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number };
planes: PlaneState[];
players: PlayerSummary[];
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
for (let attempt = 1; ; attempt++) {
const res = await page.request.get('/api/state');
if (res.status() === 200) return (await res.json()) as StateBody;
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
await page.waitForTimeout(500);
}
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
async function nearestToHub(page: Page): Promise<string> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)[0].icao;
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const sessionId = await mintPlayerSession(page, PLAYER_B);
await applySession(page, sessionId);
return { ctx, page };
}
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
async function healStarterHome(page: Page): Promise<void> {
await clearQueueViaApi(page, STARTER);
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight starter to land during cleanup',
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
}
if (p.locationIcao !== HUB) {
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for the starter to land back at the hub during cleanup',
})
.toBe(HUB);
}
}
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
async function waitForGroundedFleet(page: Page): Promise<void> {
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
timeout: 120_000,
message: 'waiting for the fleet to be fully grounded',
})
.toBe('idle');
}
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
test.beforeEach(async ({ page }) => {
await authenticate(page); // player A — the seeded local player that owns plane-starter
});
test.afterEach(async ({ page }) => {
try {
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
await healStarterHome(page);
await setScale(page, WORLD);
} finally {
if (bCtx) await bCtx.close();
bCtx = null;
}
});
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
test.afterAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
// Verify through the same surface the game uses — mint a local session just for this read.
const sessionId = await mintPlayerSession(page);
await applySession(page, sessionId);
const s = (await getState(page)) as StateBody;
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
} finally {
await ctx.close();
}
});
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
const b = await mintPlayerB(browser);
bCtx = b.ctx;
await waitForGroundedFleet(page);
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
const dest = await nearestToHub(page);
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
await setFuelFull(page, STARTER);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(500);
const s = await getState(page); // tick-on-read — advances while any plane flies
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
const now = s.simState.clockMs;
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
prev = now;
}
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: "waiting for A's plane to land",
})
.toBe('available');
const frozen1 = await getState(page);
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
const frozen2 = await getState(page);
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
});
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
const b = await mintPlayerB(browser);
bCtx = b.ctx;
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
await waitForGroundedFleet(page);
const sA = await getState(page); // A's (local session's) view of the shared world
const sB = await getState(b.page); // B's view — same world…
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
const starter = sA.planes.find((p) => p.id === STARTER)!;
expect(starter.ownerId).toBe('local');
expect(starter.ownerName).toBe(LOCAL_USERNAME);
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
expect(bSummary, 'the players summary lists player B').toBeDefined();
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
expect(flyForeign.status()).toBe(403);
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
expect(sellForeign.status()).toBe(403);
// …and B cannot act on A's.
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA.status()).toBe(403);
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
// so nothing else can touch either economy between reads.
const cA = sA.account.cash;
const cB = sB.account.cash;
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
expect(grant.status()).toBe(200);
const granted = ((await grant.json()) as StateBody).account.cash;
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
const sBAfter = await getState(b.page);
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
for (const s of [sA, sBAfter]) {
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
}
});
+26 -15
View File
@@ -20,6 +20,12 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale —
// a lagged tick can only finish the leg sooner, never skip an observed state.
const FAST = 10_000;
// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's
// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain.
const CHAIN = 4_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000) const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
@@ -285,14 +291,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE); // Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
@@ -366,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
// to arrival, then fly home. // to arrival, then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200); expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival waits — FAST is safe and ~10× quicker than SCALE here.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// Re-set SCALE for the return leg. // Re-set FAST for the return leg.
await setScale(page, SCALE); await setScale(page, FAST);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
@@ -457,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
expect(flyRes.status()).toBe(200); expect(flyRes.status()).toBe(200);
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued // Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a // return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. // CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
await setScale(page, SCALE); await setScale(page, CHAIN);
await expect.poll(async () => { await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === HUB; return !!p && p.status === 'flying' && p.destIcao === HUB;
@@ -566,8 +574,10 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up… // Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must
await setScale(page, SCALE); // stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the
// snapshot window below is sized for this rate.
await setScale(page, 2_000);
await expect.poll(async () => { await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE; return !!x && x.status === 'flying' && x.destIcao === QUEUE;
@@ -577,9 +587,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below. // Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel; const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure… expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
// 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn; // > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind
// a wrong top-up level (≤ required2, or none at all) still lands far below this bound. // load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
expect(fuel).toBeGreaterThan(1.3 * required2); expect(fuel).toBeGreaterThan(required2);
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
// Let leg 2 finish so the next test starts from a known airport. // Let leg 2 finish so the next test starts from a known airport.
@@ -603,7 +613,8 @@ test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200); expect(cancel.status()).toBe(200);
await setScale(page, SCALE); // Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
await setScale(page, FAST);
await expect.poll(async () => { await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST; return !!x && x.status === 'available' && x.locationIcao === DEST;
+14 -4
View File
@@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after. // guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts) const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
@@ -349,7 +350,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
}); });
test('two planes fly in parallel and both deliver their packages', async ({ page }) => { test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000 test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000×
const secondId = await buySecondPlaneViaUi(page); const secondId = await buySecondPlaneViaUi(page);
// Full tanks for both — the starter's may be partial from an earlier run. // Full tanks for both — the starter's may be partial from an earlier run.
@@ -379,8 +380,9 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying'); expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao); expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
// Fast-forward to the FIRST landing (the short hop)… // Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a
await setScale(page, SCALE); // lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate.
await setScale(page, FAST);
await expect await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), { .poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000, timeout: 240_000,
@@ -403,9 +405,17 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook). // The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
await setFuelFull(page, secondId); await setFuelFull(page, secondId);
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs. // Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs
// dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the
// range-edge return leg at SCALE (~24 s).
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 120_000,
message: 'waiting for both planes to land back at the hub',
})
.toBe(true);
}); });
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI — // ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
+17 -8
View File
@@ -1,11 +1,12 @@
import { eq } from 'drizzle-orm'; import { eq, max } from 'drizzle-orm';
import type { GameDb } from './client'; import type { GameDb } from './client';
import * as schema from './schema'; import * as schema from './schema';
// v1.0.0 (ISS-105): TEMPORARY single-player economy access — resolves the local player row so all // v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
// pre-multiplayer code keeps working while `account` is replaced by `players`. A later step // v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
// parameterizes these by playerId for real multiplayer; until then every read/write of the // these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
// singleton economy goes through this file and nowhere else. // account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const; export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
@@ -22,9 +23,9 @@ export async function updateLocalPlayerEconomy(
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id)); await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
} }
// v1.2.0 (ISS-109): per-player variants — the actions thread the session player's id through and // v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and
// read/write THAT row instead of the local one; getLocalPlayer stays for the snapshot/engine until // session-scoped snapshots thread the acting player's id through and read/write THAT row; the
// per-player views land in a later multiplayer step. // 'local' helpers above are reserved for world-level reads with no acting session.
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> { 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)); const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
if (!row) throw new Error(`player ${id} missing`); if (!row) throw new Error(`player ${id} missing`);
@@ -38,3 +39,11 @@ export async function updatePlayerEconomy(
): Promise<void> { ): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id)); await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
} }
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
return row?.m ?? 0;
}
+4
View File
@@ -25,6 +25,8 @@ const basePlane = (id: string): PlaneState => ({
health: 100, // v0.39.0 (ISS-069) — full-health fixture default health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null, repairUntilSimTime: null,
queue: [], queue: [],
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
ownerName: 'kamilkrzywda',
}); });
const basePackage = (id: string): PackageState => ({ const basePackage = (id: string): PackageState => ({
@@ -44,12 +46,14 @@ const basePackage = (id: string): PackageState => ({
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
}); });
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 }, account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes: [basePlane('plane-starter')], planes: [basePlane('plane-starter')],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
packages: [], packages: [],
catalog: CATALOG, catalog: CATALOG,
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
+2 -1
View File
@@ -411,7 +411,8 @@ export async function performAction(req: Request, fn: (inst: GameDb, playerId: s
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
try { try {
await runAction((inst) => fn(inst, auth.player.id)); 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 });
+56 -2
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'; import fs from 'node:fs/promises';
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state'; import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { PLANE_TYPES } from './catalog';
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
import { getStateSnapshot, SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders. // ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
@@ -20,6 +27,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
status, status,
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
}); });
const snapshot = (packages: PackageState[]): StateSnapshot => ({ const snapshot = (packages: PackageState[]): StateSnapshot => ({
@@ -29,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
sellFraction: 0.5, sellFraction: 0.5,
planes: [], planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
packages, packages,
}); });
@@ -59,3 +68,48 @@ describe('sseFrame', () => {
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']); expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
}); });
}); });
// v1.6.0 (ISS-115) — the snapshot's players array: one row per player with cash/reputation and
// how many planes they own. Runs against a throwaway seeded DB via the app singleton, exactly
// like the /api/state route tests (fresh module registry per file ⇒ fresh singleton).
describe('getStateSnapshot players', () => {
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-snapshot-players-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'players.db');
await getGameDb(); // seeds the local player + starter plane
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
it('single-player regression guard — one row for the seeded local account', async () => {
const snap = await getStateSnapshot('local');
expect(snap.players).toEqual([
{ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 },
]);
});
it('two players — cash/reputation/planeCount per owner (v1.6.0, ISS-115)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy and two planes of its own — rows minted like /api/register does.
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: 5_000, reputation: 3 });
const type = PLANE_TYPES.atr72;
for (const id of ['plane-b-1', 'plane-b-2']) {
await inst.db.insert(schema.planes).values({
id, ownerId: 'b', model: 'atr72', status: 'available',
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
});
}
const snap = await getStateSnapshot('b'); // any session sees the whole world's players
expect(snap.players).toHaveLength(2);
const local = snap.players.find((p) => p.id === 'local')!;
expect(local).toEqual({ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 }); // starter still counted once…
const b = snap.players.find((p) => p.id === 'b')!;
expect(b).toEqual({ id: 'b', username: 'player-b', cash: 5_000, reputation: 3, planeCount: 2 }); // …and both of B's planes count for B.
});
});
+57 -6
View File
@@ -2,7 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
@@ -37,6 +37,10 @@ export interface PlaneState {
health: number; health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair. // Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null; repairUntilSimTime: number | null;
// v1.4.0 (ISS-112): owner info — the snapshot carries ALL planes (shared world), so every row
// names its owner (players.id + username) and the panes can tell whose plane is whose.
ownerId: string;
ownerName: string;
} }
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against // Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
@@ -88,6 +92,19 @@ export interface PackageState {
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live // Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime). // countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
expiresRealMs: number | null; expiresRealMs: number | null;
// v1.4.0 (ISS-112): carrier name — players.username of planes.owner_id via planeId, so an
// assigned package shows WHO is carrying it in the shared world; null when unassigned.
carrierName: string | null;
}
// v1.6.0 (ISS-115): per-player summary for the leaderboard panel — rides on every snapshot
// (GET /api/state + SSE frames), so no new endpoint is needed. planeCount = planes owned.
export interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
} }
export interface StateSnapshot { export interface StateSnapshot {
@@ -96,6 +113,9 @@ export interface StateSnapshot {
// only the clock itself is reported here. // only the clock itself is reported here.
simState: { clockMs: number }; simState: { clockMs: number };
planes: PlaneState[]; planes: PlaneState[];
// v1.6.0 (ISS-115): leaderboard data — one row per player, projected from the same players +
// planes reads as everything else in this snapshot (no extra query).
players: PlayerSummary[];
packages: PackageState[]; packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so // Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell previews without a separate endpoint. // the client can compute buy/sell previews without a separate endpoint.
@@ -114,19 +134,26 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) }; return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) };
} }
export async function getStateSnapshot(): Promise<StateSnapshot> { // v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
// shape as before ({cash, reputation}), sourced per-player. v1.4.0 (ISS-112): this IS the shared
// projection for every surface that serves state — GET /api/state, action responses, the SSE
// connect frame and each tick's broadcast all call it with a player id (the broadcast projects
// once PER CONNECTED SESSION), so no two surfaces can drift. Planes are ALL planes in the world,
// each carrying owner info; packages carry carrierName when assigned to a plane.
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
const inst = await getGameDb(); const inst = await getGameDb();
const { db } = inst; const { db } = inst;
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm // NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and // 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding). // returns raw rows keyed by SQL column names (snake_case, no type decoding).
// v1.0.0 (ISS-105): the snapshot's `account` field is now the local player row — same shape, const [player, simRows, planeRows, packageRows, playerRows] = await Promise.all([
// resolved through the temporary single-player helper until multiplayer lands. getPlayer(inst, playerId),
const [player, simRows, planeRows, packageRows] = await Promise.all([
getLocalPlayer(inst),
db.select().from(schema.simState), db.select().from(schema.simState),
db.select().from(schema.planes), db.select().from(schema.planes),
db.select().from(schema.packages), db.select().from(schema.packages),
// v1.4.0 (ISS-112): one players read serves both owner joins — plane rows via ownerId and
// package carrier names via planeId → planes.owner_id → players.username.
db.select().from(schema.players),
]); ]);
const simRow = simRows[0]; const simRow = simRows[0];
if (!simRow) throw new Error('game state missing sim_state row'); if (!simRow) throw new Error('game state missing sim_state row');
@@ -147,6 +174,15 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
// body entirely, so clients never see jobs they could not dispatch. // body entirely, so clients never see jobs they could not dispatch.
const pool = poolAirports(planeRows); const pool = poolAirports(planeRows);
// v1.4.0 (ISS-112): owner joins — every plane row names its owner; a package's carrier is the
// username of ITS PLANE'S owner (planeId → planes.owner_id → players.username), null when unassigned.
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
const planeById = new Map(planeRows.map((p) => [p.id, p]));
// v1.6.0 (ISS-115): leaderboard — count owned planes per player from the rows already loaded above.
const planeCountByOwner = new Map<string, number>();
for (const p of planeRows) planeCountByOwner.set(p.ownerId, (planeCountByOwner.get(p.ownerId) ?? 0) + 1);
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its // Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock. // departure values — live position and fuel are pure functions of the clock.
const livePlane = (p: schema.PlaneRow) => { const livePlane = (p: schema.PlaneRow) => {
@@ -195,8 +231,18 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
queue: parseIcaoQueue(p.queue), queue: parseIcaoQueue(p.queue),
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
repairUntilSimTime: p.repairUntilSimTime, repairUntilSimTime: p.repairUntilSimTime,
ownerId: p.ownerId, // v1.4.0 (ISS-112)
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
}; };
}), }),
// v1.6.0 (ISS-115): leaderboard rows — every player in the world with their economy + fleet size.
players: playerRows.map((pl) => ({
id: pl.id,
username: pl.username,
cash: pl.cash,
reputation: pl.reputation,
planeCount: planeCountByOwner.get(pl.id) ?? 0,
})),
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({ packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
id: p.id, id: p.id,
originIcao: p.originIcao, originIcao: p.originIcao,
@@ -214,6 +260,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
planeId: p.planeId, planeId: p.planeId,
createdSimTime: p.createdSimTime, createdSimTime: p.createdSimTime,
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null, expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
// v1.4.0 (ISS-112): carrier = the CARRYING PLANE's owner — unassigned packages stay null.
carrierName: (() => {
const plane = p.planeId ? planeById.get(p.planeId) : undefined;
return plane ? usernameByPlayerId.get(plane.ownerId) ?? plane.ownerId : null;
})(),
})), })),
}; };
} }
+29 -28
View File
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 }, simState: { clockMs: 0 },
planes: [], planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
packages: [], packages: [],
catalog: [ catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 }, { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
@@ -55,8 +56,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
@@ -83,7 +84,7 @@ describe('planesGeoJson', () => {
snapshot({ snapshot({
catalog: [], catalog: [],
planes: [ planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -95,7 +96,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
@@ -113,7 +114,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -127,7 +128,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -148,7 +149,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -161,7 +162,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -173,7 +174,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -185,7 +186,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -197,7 +198,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -217,7 +218,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -229,7 +230,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -246,7 +247,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -258,8 +259,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -273,7 +274,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest. // Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -288,7 +289,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('skips a grounded plane whose locationIcao is missing from the airport index', () => { it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -298,7 +299,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a repairing plane, even with a non-empty queue', () => { it('draws nothing for a repairing plane, even with a non-empty queue', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -308,7 +309,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a flying plane with an empty queue', () => { it('draws nothing for a flying plane with an empty queue', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -319,7 +320,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD. // Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -336,8 +337,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => { it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -347,9 +348,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => { it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] }, { id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -491,7 +492,7 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°. // leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson( const fc = queuedRoutesGeoJson(
[ [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
AIRPORTS, AIRPORTS,
); );
@@ -529,7 +530,7 @@ describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (I
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
const snap = snapshot({ const snap = snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [] }, { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
], ],
}); });
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!); lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
+6
View File
@@ -77,6 +77,7 @@ describe('OpenAPI spec', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
@@ -86,12 +87,14 @@ describe('OpenAPI spec', () => {
'fuelPricePerL', 'fuelPricePerL',
'packages', 'packages',
'planes', 'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction', 'sellFraction',
'simState', 'simState',
]); ]);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState'); expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState'); expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState'); expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState'); expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry'); expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number'); expect(snap.fuelPricePerL.type).toBe('number');
@@ -128,6 +131,8 @@ describe('OpenAPI spec', () => {
'lon', 'lon',
'maxFuel', 'maxFuel',
'model', 'model',
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
'ownerName', // v1.4.0 (ISS-112) — owner's username
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started 'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing 'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'status', 'status',
@@ -137,6 +142,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(pkg).sort()).toEqual([ expect(Object.keys(pkg).sort()).toEqual([
'cargoCount', 'cargoCount',
'cargoType', 'cargoType',
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
'createdSimTime', 'createdSimTime',
'deadlineSimTime', 'deadlineSimTime',
'destIcao', 'destIcao',
+24 -1
View File
@@ -707,6 +707,11 @@ export const openApiSpec = {
type: 'array', type: 'array',
items: { $ref: '#/components/schemas/PlaneState' }, items: { $ref: '#/components/schemas/PlaneState' },
}, },
players: {
type: 'array',
description: 'Leaderboard rows (v1.6.0, ISS-115) — one per player in the world.',
items: { $ref: '#/components/schemas/PlayerSummary' },
},
packages: { packages: {
type: 'array', type: 'array',
items: { $ref: '#/components/schemas/PackageState' }, items: { $ref: '#/components/schemas/PackageState' },
@@ -719,7 +724,7 @@ export const openApiSpec = {
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' }, fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' }, sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
}, },
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], required: ['account', 'simState', 'planes', 'players', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
}, },
CatalogEntry: { CatalogEntry: {
type: 'object', type: 'object',
@@ -750,6 +755,18 @@ export const openApiSpec = {
}, },
required: ['clockMs'], required: ['clockMs'],
}, },
PlayerSummary: {
type: 'object',
description: 'One leaderboard row (v1.6.0, ISS-115) — a player\'s economy plus how many planes they own.',
properties: {
id: { type: 'string' },
username: { type: 'string' },
cash: { type: 'number' },
reputation: { type: 'number' },
planeCount: { type: 'integer', description: 'Number of planes in the world owned by this player.' },
},
required: ['id', 'username', 'cash', 'reputation', 'planeCount'],
},
LoadPackageRequest: { LoadPackageRequest: {
type: 'object', type: 'object',
properties: { properties: {
@@ -934,6 +951,8 @@ export const openApiSpec = {
description: description:
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.', 'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
}, },
ownerId: { type: 'string', description: 'Owner\'s players.id (v1.4.0, ISS-112) — the snapshot carries ALL planes in the shared world.' },
ownerName: { type: 'string', description: 'Owner\'s username (v1.4.0, ISS-112).' },
}, },
required: [ required: [
'id', 'id',
@@ -951,6 +970,8 @@ export const openApiSpec = {
'queue', 'queue',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100] 'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing 'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
'ownerName', // v1.4.0 (ISS-112) — owner's username
], ],
}, },
PackageState: { PackageState: {
@@ -974,6 +995,7 @@ export const openApiSpec = {
planeId: { type: ['string', 'null'] }, planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' }, createdSimTime: { type: 'integer' },
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' }, expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
carrierName: { type: ['string', 'null'], description: 'Carrier name (v1.4.0, ISS-112) — players.username of the carrying plane\'s owner via planeId → planes.owner_id; null when unassigned.' },
}, },
required: [ required: [
'id', 'id',
@@ -991,6 +1013,7 @@ export const openApiSpec = {
'planeId', 'planeId',
'createdSimTime', 'createdSimTime',
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise 'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
], ],
}, },
}, },
+167 -2
View File
@@ -5,13 +5,13 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed'; import { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
deliverPackages, deliverPackages,
@@ -497,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)', () => {
@@ -670,6 +742,32 @@ describe('grounded fleet freezes the world clock (#135)', () => {
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
}); });
it('N-player fleet: one owner airborne keeps the clock running; all owners grounded freezes it (ISS-113)', async () => {
const inst = await freshDb('nplayer-clock.db');
// A second player with their own GROUNDED plane — the world now has two owners.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
await inst.db.insert(schema.planes).values({
id: 'plane-b', ownerId: 'player-b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
});
// A (local) takes off while B stays grounded → the world advances — one owner flying is enough…
await makeWorldActive(inst);
vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // …at the fixed world rate
// …and grounding A's plane (B was already grounded) freezes the whole world.
await inst.db.update(schema.planes)
.set({ status: 'available', destIcao: null, departureSimTime: null })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // a full second of real idleness with every owner grounded
const frozen = await tickOnce(inst);
expect(frozen.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // frozen — no advance…
expect(frozen.lastRealTickMs).toBe(T0 + 2_000); // …but the real-time anchor keeps tracking
});
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => { it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
// Drain the open market the way wall-clock expiry would (removal paths run while frozen). // Drain the open market the way wall-clock expiry would (removal paths run while frozen).
@@ -839,6 +937,73 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true); expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
}); });
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
// against the math, not against itself.
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
const fc = await loadAirportsGeoJson();
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
return Math.round(
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
);
}
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
const inst = await freshDb('pool-shared-rep.db');
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
}
});
it('MAX picks the local row when local leads — symmetric to the player-b case (v1.5.0)', async () => {
const inst = await freshDb('pool-shared-rep-local.db');
// Local is far ahead of b's starting rep — a "prefer non-local" implementation would pick b and fail here.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to b's starting rep
}
});
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
const inst = await freshDb('pool-single-rep.db');
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60));
}
});
}); });
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the // Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
+29 -15
View File
@@ -1,7 +1,7 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -9,7 +9,7 @@ import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state'; import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
import { broadcast } from '@/lib/sse'; import { broadcast, subscribedPlayerIds } from '@/lib/sse';
import { import {
JOB_LIFETIME_MS, JOB_LIFETIME_MS,
@@ -251,12 +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 player read per award so multiple deliveries in one tick all count. // v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
const fresh = await getLocalPlayer(inst); // multiple deliveries in one tick all count.
const fresh = await getPlayer(inst, p.ownerId);
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the // v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded). // distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0; const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await updateLocalPlayerEconomy(inst, { await updatePlayerEconomy(inst, p.ownerId, {
cash: fresh.cash + pk.reward, cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100, reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
}); });
@@ -306,14 +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 player read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. // v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
const fresh = await getLocalPlayer(inst); // delivery awards above) so a top-up in this tick sees current cash.
const fresh = await getPlayer(inst, p.ownerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) { if (cost === 0 || fresh.cash >= cost) {
queue.shift(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; topUpFuel = target;
if (cost > 0) await updateLocalPlayerEconomy(inst, { cash: Math.round((fresh.cash - cost) * 100) / 100 }); if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 });
} }
} }
} }
@@ -382,8 +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 local player's CURRENT reputation quality — read once per spawn batch. // v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
const player = await getLocalPlayer(inst); // v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
// agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
// player instead of the seeded local row.
const reputation = await getSharedWorldReputation(inst);
let n = 0; let n = 0;
for (const originIcao of pool) { for (const originIcao of pool) {
@@ -413,7 +418,7 @@ export async function spawnPoolPackages(
clockMs, clockMs,
id: randomUUID(), id: randomUUID(),
createdRealMs: real, createdRealMs: real,
reputation: player.reputation, reputation,
}), }),
); );
openByBand[bandIdx]++; openByBand[bandIdx]++;
@@ -431,12 +436,21 @@ export async function spawnPoolPackages(
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged, // package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for // not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
// other clients too. // other clients too.
// v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's
// player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only,
// so every client sees its own account row in every push. Same shared projection as GET /api/state
// (getStateSnapshot + sseFrame), so pushed frames can never drift from polled ones. A session whose
// players row vanished mid-stream is skipped per-tick with a log — the others keep flowing.
async function broadcastState(): Promise<void> { async function broadcastState(): Promise<void> {
try { const frames = new Map<string, string>();
broadcast(JSON.stringify(sseFrame(await getStateSnapshot()))); for (const playerId of subscribedPlayerIds()) {
} catch (err) { try {
console.error('flight: state broadcast failed', err); frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId))));
} catch (err) {
console.error('flight: state broadcast failed', err);
}
} }
if (frames.size > 0) broadcast(frames);
} }
export function tickNow(): Promise<schema.SimStateRow> { export function tickNow(): Promise<schema.SimStateRow> {
+38 -18
View File
@@ -1,45 +1,65 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { broadcast, subscribe } from './sse'; import { broadcast, subscribedPlayerIds, subscribe } from './sse';
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or // ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
// server needed. Subscribers leak across tests in this file (module-level state), which is fine: // leak across tests in this file (module-level state), which is fine: each test asserts on its own
// each test only asserts on its own arrays. // arrays and uses distinct player ids where isolation matters. v1.4.0 (ISS-112): keyed by PLAYER ID
// — a frame for one player must never reach another player's streams.
describe('lib/sse', () => { describe('lib/sse', () => {
it('delivers broadcasts to subscribers and stops after unsubscribe', () => { it('delivers broadcasts to subscribers of the SAME player only, and stops after unsubscribe', () => {
const a: string[] = []; const a: string[] = [];
const b: string[] = []; const b: string[] = []; // same player as a — both get its frames
const unsubA = subscribe((t) => a.push(t)); const c: string[] = []; // different player — never sees their frames
const unsubB = subscribe((t) => b.push(t)); const unsubA = subscribe('p-a', (t) => a.push(t));
const unsubB = subscribe('p-a', (t) => b.push(t));
const unsubC = subscribe('p-c', (t) => c.push(t));
broadcast('x'); broadcast(new Map([['p-a', 'x']]));
expect(a).toEqual(['x']); expect(a).toEqual(['x']);
expect(b).toEqual(['x']); expect(b).toEqual(['x']); // same group
expect(c).toEqual([]); // other player's frame never crosses over
unsubA(); unsubA();
broadcast('y'); broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
expect(a).toEqual(['x']); // unsubscribed — no more events expect(a).toEqual(['x']); // unsubscribed — no more events
expect(b).toEqual(['x', 'y']); expect(b).toEqual(['x', 'y']);
expect(c).toEqual(['z']);
unsubB(); unsubB();
unsubC();
}); });
it('a throwing subscriber does not break the others', () => { it('subscribedPlayerIds lists exactly the players with live streams, dropping empty groups', () => {
const got: string[] = []; const got: string[] = [];
subscribe(() => { const unsub1 = subscribe('p-ids-1', (t) => got.push(t));
const unsub2 = subscribe('p-ids-1', (t) => got.push(t)); // second member of the same group
const unsub3 = subscribe('p-ids-2', (t) => got.push(t));
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
unsub1(); // p-ids-1 still has one member — id stays listed
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
unsub3(); // p-ids-2's last stream goes → its projections stop
expect(subscribedPlayerIds()).toEqual(['p-ids-1']);
unsub2();
});
it('a throwing subscriber does not break the others in its group', () => {
const got: string[] = [];
subscribe('p-boom', () => {
throw new Error('boom'); throw new Error('boom');
}); });
const unsub = subscribe((t) => got.push(t)); const unsub = subscribe('p-boom', (t) => got.push(t));
broadcast('z'); broadcast(new Map([['p-boom', 'z']]));
expect(got).toEqual(['z']); expect(got).toEqual(['z']);
unsub(); unsub();
}); });
it('unsubscribe is idempotent', () => { it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
const got: string[] = []; const got: string[] = [];
const unsub = subscribe((t) => got.push(t)); const unsub = subscribe('p-idem', (t) => got.push(t));
unsub(); unsub();
unsub(); // second call must not throw unsub(); // second call must not throw
broadcast('w'); broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
expect(got).toEqual([]); expect(got).toEqual([]);
}); });
}); });
+35 -14
View File
@@ -1,29 +1,50 @@
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app // ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background // runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because // sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route // Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would // handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
// give each its own empty set and no push would ever be delivered. // give each its own empty map and no push would ever be delivered. v1.4.0 (ISS-112): subscribers are
// keyed by PLAYER ID — the engine projects one snapshot per connected player and pushes each frame
// only to that player's group, so every client sees its own account row in every pushed frame.
type Push = (text: string) => void; type Push = (text: string) => void;
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> }; const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set()); const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
export function subscribe(fn: Push): () => void { export function subscribe(playerId: string, fn: Push): () => void {
subscribers.add(fn); let group = subscribers.get(playerId);
if (!group) {
group = new Set();
subscribers.set(playerId, group);
}
group.add(fn);
return () => { return () => {
subscribers.delete(fn); const s = subscribers.get(playerId);
if (!s) return; // already unsubscribed — idempotent
s.delete(fn);
if (s.size === 0) subscribers.delete(playerId); // drop the empty group so projections stop for it
}; };
} }
// Call each subscriber safely — one dead stream must not take the others down. // v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
export function broadcast(text: string): void { // projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
for (const fn of [...subscribers]) { export function subscribedPlayerIds(): string[] {
try { return [...subscribers.keys()];
fn(text); }
} catch {
// subscriber's stream is gone; its own cleanup unsubscribes it // Call each subscriber safely — one dead stream must not take the others down. `frames` maps
// player id → pre-serialized frame text (the engine projects per player, this only fans out).
export function broadcast(frames: Map<string, string>): void {
for (const [playerId, text] of frames) {
const group = subscribers.get(playerId);
if (!group) continue; // unsubscribed between projection and push — fine
for (const fn of [...group]) {
try {
fn(text);
} catch {
// subscriber's stream is gone; its own cleanup unsubscribes it
}
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "1.2.0", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",