// Phase 29 end-to-end coverage for the gear popover. The spec mocks // the gateway with a mixed-kind report (local + foreign + uninhabited // + unidentified planets, a battle, a bombing, a cargo route, a // non-zero drive tech for fog math), then walks the popover through // the toggles and asserts the renderer state via the // `__galaxyDebug` accessors: // // * `getMapPrimitives()` — every primitive carries a `visible` // flag mirroring the renderer's hide set. The spec counts the // visible-foreign-planet primitives, etc. // * `getMapFog()` — the current visibility-fog circle list. // * `getMapCamera()` — the wrap-mode test reads the centre before // and after the flip to confirm camera preservation. // * `getMapRenderCount()` — painted-frame counter used by the // render-on-demand specs at the bottom of this file: an idle map // must not keep repainting, and a released drag must not coast // (the `decelerate` plugin was removed). import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { expect, test, type Page } from "@playwright/test"; import { ByteBuffer } from "flatbuffers"; import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; import { UUID } from "../../src/proto/galaxy/fbs/common"; import { GameReportRequest } from "../../src/proto/galaxy/fbs/report"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { buildMyGamesListPayload, type GameFixture, } from "./fixtures/lobby-fbs"; import { buildReportPayload } from "./fixtures/report-fbs"; const SESSION_ID = "phase-29-map-toggles-session"; const GAME_ID = "29292929-2929-2929-2929-292929292929"; const RACE = "Earthlings"; // FlightDistance = driveTech * 40; pick drive=10 → reach 400. // VisibilityDistance = driveTech * 30 → fog radius 300. const DRIVE_TECH = 10; interface MockOpts { currentTurn: number; } async function mockGateway(page: Page, opts: MockOpts): Promise { const game: GameFixture = { gameId: GAME_ID, gameName: "Phase 29 Game", gameType: "private", status: "running", ownerUserId: "user-1", minPlayers: 2, maxPlayers: 8, enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000), createdAtMs: BigInt(Date.now() - 86_400_000), updatedAtMs: BigInt(Date.now()), currentTurn: opts.currentTurn, }; await page.route( "**/edge.v1.Gateway/ExecuteCommand", async (route) => { const reqText = route.request().postData(); if (reqText === null) { await route.fulfill({ status: 400 }); return; } const req = fromJson( ExecuteCommandRequestSchema, JSON.parse(reqText) as JsonValue, ); let resultCode = "ok"; let payload: Uint8Array; switch (req.messageType) { case "lobby.my.games.list": payload = buildMyGamesListPayload([game]); break; case "user.games.report": { GameReportRequest.getRootAsGameReportRequest( new ByteBuffer(req.payloadBytes), ).gameId(new UUID()); payload = buildReportPayload({ turn: opts.currentTurn, mapWidth: 4000, mapHeight: 4000, race: RACE, players: [{ name: RACE, drive: DRIVE_TECH }], // Two LOCAL planets near the centre so the reach + // fog math has anchors. The foreign planet at // (1500, 1000) is 500 units from the closest LOCAL // — outside reach (400), so the unreachable filter // toggles flag this one when enabled. localPlanets: [ { number: 1, name: "Earth", x: 1000, y: 1000, size: 1000, resources: 10, }, { number: 2, name: "Mars", x: 1200, y: 1000, size: 1000, resources: 10, }, ], otherPlanets: [ { number: 3, name: "Frontier", x: 1500, y: 1000, owner: "Federation", size: 800, resources: 5, }, ], uninhabitedPlanets: [ { number: 4, name: "Rock", x: 1100, y: 1100, size: 500, resources: 1, }, ], unidentifiedPlanets: [ { number: 5, x: 2500, y: 1000 }, ], battles: [ { id: "8c0c1f64-b0f8-4e7d-8c2c-3e1d0a0b0001", planet: 3, shots: 4 }, ], bombings: [ { planetNumber: 3, planet: "Frontier", owner: "Federation", attacker: RACE, production: "", industry: 100, population: 200, colonists: 50, attackPower: 5, wiped: false, }, ], routes: [ { sourcePlanetNumber: 1, entries: [ { loadType: "COL", destinationPlanetNumber: 2 }, ], }, ], }); break; } default: resultCode = "internal_error"; payload = new Uint8Array(); } const body = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode, payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body, }); }, ); // Keep the push stream open so the revocation watcher does not // sign the session out mid-test (same convention as // `game-shell-map.spec.ts`). await page.route( "**/edge.v1.Gateway/SubscribeEvents", async () => { await new Promise(() => {}); }, ); } async function bootSession(page: Page): Promise { await page.goto("/__debug/store"); await expect(page.getByTestId("debug-store-ready")).toBeVisible(); await page.waitForFunction(() => window.__galaxyDebug?.ready === true); await page.evaluate(() => window.__galaxyDebug!.clearSession()); await page.evaluate( (id) => window.__galaxyDebug!.setDeviceSessionId(id), SESSION_ID, ); } async function openGame(page: Page): Promise { await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); // Wait for the renderer's debug accessor to register so the // `getMapPrimitives` call below picks up real data instead of an // empty stub. The renderer registers it inside // `runSerializedMount`, which awaits Pixi init. await page.waitForFunction(() => { const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? []; return prims.length > 0; }); } interface PrimitiveLite { id: number; visible: boolean; } async function visiblePlanets(page: Page): Promise { return await page.evaluate(() => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; // Planet primitive ids are the engine planet numbers — small // positive integers ≤ planetCount. Other categories use either // signed-negative high-bit-prefix ids (cargo route 0x80…, battle // 0xa0…, bombing 0xc0…) or large positive offsets (ship groups // at 1e8+). The `0 < id < 1e7` window covers the planet range // and excludes both. return prims .filter((p) => p.visible && p.id > 0 && p.id < 10_000_000) .map((p) => p.id) .sort((a, b) => a - b); }); } async function visibleHighBitCount( page: Page, prefix: number, ): Promise { // JS bitwise `&` always returns a signed int32. Convert both // sides to uint32 via `>>> 0` AFTER the mask so the comparison // is well-defined for high-bit-prefix ids that arrive as // negative Numbers (cargo route 0x80…, battle 0xa0…, bombing // 0xc0…) as well as for the positive `prefix` literal passed in. return await page.evaluate((p: number) => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; const expected = p >>> 0; return prims.filter( (prim) => prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected, ).length; }, prefix); } test("gear popover toggles a planet kind off and cascades onto its markers", async ({ page, }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); // Baseline — every planet shows up, plus the battle X-cross (2 // LinePrim) and the bombing ring on the foreign planet. expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]); expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2); expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1); await page.getByTestId("map-toggles-trigger").click(); await expect(page.getByTestId("map-toggles-surface")).toBeVisible(); await page.getByTestId("map-toggles-foreign-planets").click(); // The cascade applies asynchronously through the Svelte effect; // wait for the foreign planet to drop out of the visible set // before asserting on the markers — both updates happen in the // same effect tick so once the planet is gone the markers are // too. await page.waitForFunction(() => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly { id: number; visible: boolean; }[]; const planet3 = prims.find((p) => p.id === 3); return planet3 !== undefined && planet3.visible === false; }); expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]); expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0); expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0); }); test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({ page, }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); // Defaults: fog on; one circle per LOCAL planet, radius // `30 * driveTech = 300`. const initialFog = await page.evaluate( () => window.__galaxyDebug!.getMapFog!().circles, ); expect(initialFog.length).toBe(2); expect(initialFog[0].radius).toBe(300); expect(initialFog[1].radius).toBe(300); await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-visible-hyperspace").click(); // The effect re-run is async; wait for the fog payload to clear // instead of reading it on the next tick. await page.waitForFunction( () => window.__galaxyDebug!.getMapFog!().circles.length === 0, ); // Toggling back on rebuilds the fog circles for the same planets. await page.getByTestId("map-toggles-visible-hyperspace").click(); await page.waitForFunction( () => window.__galaxyDebug!.getMapFog!().circles.length === 2, ); }); test("wrap mode radios flip the renderer and the camera centre survives", async ({ page, }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); // Confirm the renderer starts in torus mode. await page.waitForFunction( () => window.__galaxyDebug?.getMapMode?.() === "torus", ); const initial = await page.evaluate(() => window.__galaxyDebug!.getMapCamera!(), ); expect(initial).not.toBeNull(); const startCentre = initial!.camera; await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-wrap-no-wrap").click(); // `setWrapMode` triggers a full Pixi remount; wait for the // renderer to settle into the new mode and the debug surface to // re-register before reading the camera. The mode provider is // re-bound inside `runSerializedMount` after `createRenderer` // resolves, so observing `getMapMode() === "no-wrap"` is the // canonical "remount complete" signal. await page.waitForFunction( () => window.__galaxyDebug?.getMapMode?.() === "no-wrap", ); const after = await page.evaluate(() => window.__galaxyDebug!.getMapCamera!(), ); expect(after).not.toBeNull(); expect( Math.abs(after!.camera.centerX - startCentre.centerX), ).toBeLessThanOrEqual(1); expect( Math.abs(after!.camera.centerY - startCentre.centerY), ).toBeLessThanOrEqual(1); }); test("toggle state persists across a page reload", async ({ page }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-battle-markers").click(); await page.getByTestId("map-toggles-bombing-markers").click(); // Independent flips: turning battle off must not touch bombing. expect( await page.getByTestId("map-toggles-battle-markers").isChecked(), ).toBe(false); expect( await page.getByTestId("map-toggles-bombing-markers").isChecked(), ).toBe(false); await page.reload(); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await page.waitForFunction(() => { const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? []; return prims.length > 0; }); await page.getByTestId("map-toggles-trigger").click(); expect( await page.getByTestId("map-toggles-battle-markers").isChecked(), ).toBe(false); expect( await page.getByTestId("map-toggles-bombing-markers").isChecked(), ).toBe(false); // Battle X-cross and bombing ring are hidden in the renderer. expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0); expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0); }); // settledRenderCount waits out the mount/resize paint burst and returns // the painted-frame count once it stops advancing. The renderer runs // render-on-demand, so the count goes flat as soon as the scene is // static; the loop bails after a fixed number of samples so a renderer // that never settles fails the spec instead of hanging. async function settledRenderCount(page: Page): Promise { await page.waitForFunction( () => (window.__galaxyDebug?.getMapRenderCount?.() ?? 0) > 0, ); return await page.evaluate(async () => { const read = (): number => window.__galaxyDebug!.getMapRenderCount!() ?? 0; let prev = read(); for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 150)); const cur = read(); if (cur === prev) return cur; prev = cur; } return prev; }); } test("render-on-demand: an idle map does not repaint, a content mutation does", async ({ page, }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); const settled = await settledRenderCount(page); // Idle window: no pointer interaction, no toggle. A continuous // auto-render loop would add ~40 frames over 700ms at 60fps; render // -on-demand adds none. The +2 slack tolerates a lone stray frame // (e.g. a late layout settle) while still failing hard if the // always-on loop ever comes back. await page.waitForTimeout(700); const afterIdle = await page.evaluate( () => window.__galaxyDebug!.getMapRenderCount!(), ); expect(afterIdle).toBeLessThanOrEqual(settled + 2); // Toggling the fog mutates the scene graph and must repaint. await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-visible-hyperspace").click(); await page.waitForFunction( () => window.__galaxyDebug!.getMapFog!().circles.length === 0, ); // The repaint lands on the next shared-ticker frame after the fog // input changed, so poll for the counter to advance rather than // reading it synchronously (the timing of that frame is racy). await page.waitForFunction( (baseline) => window.__galaxyDebug!.getMapRenderCount!() > baseline, afterIdle, ); }); test("pan stops immediately on release: no inertia tail after a drag", async ({ page, }) => { await mockGateway(page, { currentTurn: 1 }); await bootSession(page); await openGame(page); await settledRenderCount(page); const canvas = page.getByTestId("active-view-map").locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) return; const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; // Decisive drag with intermediate steps so pixi-viewport's drag // plugin clears its movement threshold. await page.mouse.move(cx, cy); await page.mouse.down(); for (let step = 1; step <= 16; step++) { await page.mouse.move(cx - (160 * step) / 16, cy - (120 * step) / 16); } await page.mouse.up(); // Let the final drag frame flush, then snapshot the camera centre // and confirm it does not drift over the next ~500ms. Without the // `decelerate` plugin the viewport freezes the instant the drag // ends, so the centre is identical; a re-introduced inertia tail // would coast it by many world units. (If the synthetic drag never // registered the centre is also static, so the spec never // false-fails — it only catches a returning inertia tail.) await page.waitForTimeout(120); const atRelease = await page.evaluate( () => window.__galaxyDebug!.getMapCamera!()!.camera, ); await page.waitForTimeout(500); const later = await page.evaluate( () => window.__galaxyDebug!.getMapCamera!()!.camera, ); expect(Math.abs(later.centerX - atRelease.centerX)).toBeLessThan(1); expect(Math.abs(later.centerY - atRelease.centerY)).toBeLessThan(1); });