v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)
CI / build + unit + e2e (pull_request) Successful in 13m33s
CI / build + unit + e2e (pull_request) Successful in 13m33s
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- **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`.
|
- **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`.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user