From fe84d9a0bbfede1a2926bbcb28efac44dc95947e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 26 Sep 2026 20:26:10 +0000 Subject: [PATCH] =?UTF-8?q?v1.7.0:=20multiplayer=20test=20coverage=20?= =?UTF-8?q?=E2=80=94=20player-scoped=20fixtures=20+=20N-player=20clock=20v?= =?UTF-8?q?erification=20(ISS-116,=20ISS-113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + app/api/test/delete-plane/route.ts | 69 +++++++ e2e/auth.ts | 7 +- e2e/multiplayer.spec.ts | 283 +++++++++++++++++++++++++++++ lib/sim/engine.test.ts | 44 +++++ package.json | 2 +- 6 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 app/api/test/delete-plane/route.ts create mode 100644 e2e/multiplayer.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb26f0..6d6272a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 - **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`. diff --git a/app/api/test/delete-plane/route.ts b/app/api/test/delete-plane/route.ts new file mode 100644 index 0000000..03f63dc --- /dev/null +++ b/app/api/test/delete-plane/route.ts @@ -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; + 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 }); + } +} diff --git a/e2e/auth.ts b/e2e/auth.ts index d8a49f8..bcd7a64 100644 --- a/e2e/auth.ts +++ b/e2e/auth.ts @@ -9,8 +9,11 @@ import { expect } from '@playwright/test'; export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts -export async function mintPlayerSession(page: Page): Promise { - const res = await page.request.post('/api/test/mint-player-session'); +// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has +// 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 { + 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); return ((await res.json()) as { sessionId: string }).sessionId; } diff --git a/e2e/multiplayer.spec.ts b/e2e/multiplayer.spec.ts new file mode 100644 index 0000000..c08649e --- /dev/null +++ b/e2e/multiplayer.spec.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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>; 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 { + 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 { + 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> | 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); + } +}); diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index b73c668..6b197d8 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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 }); + 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 () => { 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). @@ -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 () => { 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 — diff --git a/package.json b/package.json index f153815..f00f6e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.6.0", + "version": "1.7.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0