Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5093fba82e | ||
|
|
cde4334946 | ||
|
|
127d8f97f0 | ||
|
|
479c5448f7 | ||
|
|
d2b799e572 | ||
|
|
664906b8ce | ||
|
|
b60006a949 | ||
|
|
fe84d9a0bb | ||
|
|
43c4bb97a9 | ||
|
|
b04e504263 |
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.10.0 — 2026-09-27 22:31
|
||||||
|
- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn).
|
||||||
|
- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button.
|
||||||
|
- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion).
|
||||||
|
|
||||||
|
## v1.9.0 — 2026-09-27 20:57
|
||||||
|
- **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is.
|
||||||
|
|
||||||
|
## v1.8.0 — 2026-09-27 20:13
|
||||||
|
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
|
||||||
|
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
|
||||||
|
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
|
||||||
|
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
|
||||||
|
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
|
||||||
|
|
||||||
|
## v1.7.0 — 2026-09-26 20:24
|
||||||
|
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
|
||||||
|
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
|
||||||
|
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
|
||||||
|
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
|
||||||
|
|
||||||
|
## v1.6.0 — 2026-09-26 19:35
|
||||||
|
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
|
||||||
|
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
|
||||||
|
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
|
||||||
|
|
||||||
## v1.5.0 — 2026-09-26 19:06
|
## 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -46,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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -737,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;
|
||||||
|
|||||||
+14
-6
@@ -19,6 +19,11 @@ export default function Home() {
|
|||||||
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
||||||
// feed never polls an unauthenticated /api/state.
|
// feed never polls an unauthenticated /api/state.
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
// v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map.
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
// Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must
|
||||||
|
// track whoever just signed in, not the user probed on mount (or before the last logout).
|
||||||
|
const [probeKey, setProbeKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sid = storedSession();
|
const sid = storedSession();
|
||||||
@@ -28,9 +33,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||||
.then((res) => {
|
.then(async (res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
||||||
|
else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158)
|
||||||
setAuthed(res.ok);
|
setAuthed(res.ok);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -39,7 +45,7 @@ export default function Home() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [probeKey]);
|
||||||
|
|
||||||
if (authed === null) {
|
if (authed === null) {
|
||||||
return (
|
return (
|
||||||
@@ -51,14 +57,16 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return <SignInScreen onAuthed={() => setAuthed(true)} />;
|
// v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed —
|
||||||
|
// isAdmin has to come from the user who just signed in.
|
||||||
|
return <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||||
return <AppShell onLogout={() => setAuthed(false)} />;
|
return <AppShell onLogout={() => setAuthed(false)} isAdmin={isAdmin} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell({ onLogout }: { onLogout: () => void }) {
|
function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) {
|
||||||
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
||||||
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
||||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||||
@@ -95,7 +103,7 @@ function AppShell({ onLogout }: { onLogout: () => void }) {
|
|||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
action response into the shared state, exactly like RightPane does */}
|
action response into the shared state, exactly like RightPane does */}
|
||||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
||||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-5
@@ -13,7 +13,8 @@ import { weatherFor } from '../lib/weather';
|
|||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import AdminPanel from './AdminPanel';
|
import AdminPanel from './AdminPanel';
|
||||||
import { sessionHeaders } from '../lib/authClient';
|
import Leaderboard from './Leaderboard';
|
||||||
|
import { clearStoredSession, sessionHeaders } from '../lib/authClient';
|
||||||
|
|
||||||
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
||||||
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
||||||
@@ -73,11 +74,13 @@ interface MapProps {
|
|||||||
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
|
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
|
||||||
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
// v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button.
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
||||||
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||||
export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
|
export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
// /api/airports is fetched at most once per page life: every styledata event
|
// /api/airports is fetched at most once per page life: every styledata event
|
||||||
@@ -98,6 +101,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
|
||||||
@@ -566,6 +571,14 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
|||||||
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort
|
||||||
|
// server-side session kill, drop the stored id, tell the shell to show sign-in again.
|
||||||
|
const onTopBarLogout = async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined);
|
||||||
|
clearStoredSession();
|
||||||
|
onLogout?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
@@ -589,15 +602,30 @@ 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>
|
||||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
|
||||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||||
Admin
|
Leaderboard
|
||||||
|
</button>
|
||||||
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
|
||||||
|
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
|
Admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
|
||||||
|
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
|
||||||
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||||
<AdminPanel 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ test('header shows the app version sourced from package.json', async ({ page })
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button
|
||||||
|
// (mirrors e2e/admin.spec.ts' logout assertion).
|
||||||
|
test('top bar Log out drops back to the sign-in screen', async ({ page }) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
const header = page.getByTestId('app-header');
|
||||||
|
await expect(header).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId('logout-btn').click();
|
||||||
|
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(header).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||||||
|
|
||||||
|
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||||||
|
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||||||
|
// player; player B is a second account minted through the same hook with a username override (the
|
||||||
|
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||||||
|
//
|
||||||
|
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||||||
|
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||||||
|
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||||||
|
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||||||
|
|
||||||
|
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
|
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||||||
|
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
locationIcao: string;
|
||||||
|
maxFuel: number;
|
||||||
|
queue: string[];
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
cash: number;
|
||||||
|
reputation: number;
|
||||||
|
planeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number };
|
||||||
|
planes: PlaneState[];
|
||||||
|
players: PlayerSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||||
|
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||||||
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
|
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||||
|
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||||
|
if (q.length === 0) return;
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||||||
|
async function nearestToHub(page: Page): Promise<string> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
return fc.features
|
||||||
|
.filter((f) => f.properties.icao !== HUB)
|
||||||
|
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||||
|
.sort((x, y) => x.d - y.d)[0].icao;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||||||
|
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
return { ctx, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
await clearQueueViaApi(page, STARTER);
|
||||||
|
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||||||
|
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||||||
|
if (p.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight starter to land during cleanup',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (p.locationIcao !== HUB) {
|
||||||
|
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyHome.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the starter to land back at the hub during cleanup',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||||||
|
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||||||
|
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for the fleet to be fully grounded',
|
||||||
|
})
|
||||||
|
.toBe('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||||||
|
|
||||||
|
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
try {
|
||||||
|
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||||||
|
await healStarterHome(page);
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
} finally {
|
||||||
|
if (bCtx) await bCtx.close();
|
||||||
|
bCtx = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||||||
|
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||||||
|
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
try {
|
||||||
|
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||||||
|
// Verify through the same surface the game uses — mint a local session just for this read.
|
||||||
|
const sessionId = await mintPlayerSession(page);
|
||||||
|
await applySession(page, sessionId);
|
||||||
|
const s = (await getState(page)) as StateBody;
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||||||
|
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||||||
|
|
||||||
|
const dest = await nearestToHub(page);
|
||||||
|
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||||||
|
await setFuelFull(page, STARTER);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||||||
|
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||||||
|
let prev = (await getState(page)).simState.clockMs;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||||||
|
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||||||
|
const now = s.simState.clockMs;
|
||||||
|
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: "waiting for A's plane to land",
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
|
||||||
|
const frozen1 = await getState(page);
|
||||||
|
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||||||
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||||||
|
const frozen2 = await getState(page);
|
||||||
|
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||||||
|
const b = await mintPlayerB(browser);
|
||||||
|
bCtx = b.ctx;
|
||||||
|
|
||||||
|
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||||||
|
await waitForGroundedFleet(page);
|
||||||
|
|
||||||
|
const sA = await getState(page); // A's (local session's) view of the shared world
|
||||||
|
const sB = await getState(b.page); // B's view — same world…
|
||||||
|
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||||||
|
|
||||||
|
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||||||
|
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.ownerId).toBe('local');
|
||||||
|
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||||||
|
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||||||
|
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||||||
|
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||||||
|
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||||||
|
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||||||
|
|
||||||
|
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||||||
|
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||||||
|
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyForeign.status()).toBe(403);
|
||||||
|
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||||||
|
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||||||
|
expect(sellForeign.status()).toBe(403);
|
||||||
|
|
||||||
|
// …and B cannot act on A's.
|
||||||
|
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyA.status()).toBe(403);
|
||||||
|
|
||||||
|
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||||||
|
// so nothing else can touch either economy between reads.
|
||||||
|
const cA = sA.account.cash;
|
||||||
|
const cB = sB.account.cash;
|
||||||
|
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||||||
|
expect(grant.status()).toBe(200);
|
||||||
|
const granted = ((await grant.json()) as StateBody).account.cash;
|
||||||
|
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||||||
|
const sBAfter = await getState(b.page);
|
||||||
|
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||||||
|
|
||||||
|
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||||||
|
for (const s of [sA, sBAfter]) {
|
||||||
|
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
+26
-15
@@ -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,7 +14,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
|||||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — cleanup legs land in ~1 s
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
|
||||||
@@ -93,8 +93,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
let s = await getState(page);
|
let s = await getState(page);
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~2 s real time worst case at FAST).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
}
|
}
|
||||||
if (plane.status === 'flying') {
|
if (plane.status === 'flying') {
|
||||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
||||||
@@ -104,8 +104,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
// Make sure the world rate is back at FAST so the return leg fast-forwards.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, FAST);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
@@ -122,7 +122,7 @@ test.afterEach(async ({ page }) => {
|
|||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -154,7 +154,7 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one leg to LFRS
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
@@ -221,7 +221,7 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg, fast-forwarded in cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
|
|||||||
+14
-4
@@ -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 —
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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,
|
||||||
|
|||||||
+55
-2
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import fs from 'node:fs/promises';
|
||||||
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { PLANE_TYPES } from './catalog';
|
||||||
|
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
|
||||||
|
import { getStateSnapshot, SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
||||||
|
|
||||||
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
||||||
|
|
||||||
@@ -30,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,12 +97,25 @@ export interface PackageState {
|
|||||||
carrierName: string | null;
|
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 {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
||||||
// 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.
|
||||||
@@ -166,6 +179,10 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
||||||
const planeById = new Map(planeRows.map((p) => [p.id, p]));
|
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) => {
|
||||||
@@ -218,6 +235,14 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
|
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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
+18
-1
@@ -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: {
|
||||||
|
|||||||
@@ -742,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).
|
||||||
@@ -944,6 +970,24 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
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');
|
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 —
|
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.5.0",
|
"version": "1.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user