// 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. import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { expect, test, type Page } from "@playwright/test"; import { ByteBuffer } from "flatbuffers"; import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/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( "**/galaxy.gateway.v1.EdgeGateway/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( "**/galaxy.gateway.v1.EdgeGateway/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); // every other category uses a high-bit prefix. return prims .filter((p) => p.visible && p.id < 1_000_000) .map((p) => p.id) .sort((a, b) => a - b); }); } async function visibleCount( page: Page, predicate: (id: number) => boolean, ): Promise { return await page.evaluate((pred: string) => { const fn = new Function("id", `return (${pred})(id);`) as ( id: number, ) => boolean; const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; return prims.filter((p) => p.visible && fn(p.id)).length; }, predicate.toString()); } 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]); // Two battle marker line primitives (high-bit prefix 0xa0000000). expect( await visibleCount( page, (id) => (id & 0xf0000000) === 0xa0000000, ), ).toBe(2); // One bombing ring (prefix 0xc0000000). expect( await visibleCount( page, (id) => (id & 0xf0000000) === 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 foreign planet (id 3) is gone — and its battle / bombing // markers cascaded with it. expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]); expect( await visibleCount( page, (id) => (id & 0xf0000000) === 0xa0000000, ), ).toBe(0); expect( await visibleCount( page, (id) => (id & 0xf0000000) === 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-visibility-fog").click(); const offFog = await page.evaluate( () => window.__galaxyDebug!.getMapFog!().circles, ); expect(offFog).toEqual([]); // Toggling back on rebuilds the fog circles for the same planets. await page.getByTestId("map-toggles-visibility-fog").click(); const onAgain = await page.evaluate( () => window.__galaxyDebug!.getMapFog!().circles, ); expect(onAgain.length).toBe(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); 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(); // Mount path is async (Pixi re-init takes a frame). Wait for the // camera reading to settle into the new mount and assert the // centre is within 1 px of the pre-toggle value. await page.waitForFunction(() => { const c = window.__galaxyDebug?.getMapCamera?.(); return c !== null && c !== undefined && c.camera.centerX !== undefined; }); 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 visibleCount( page, (id) => (id & 0xf0000000) === 0xa0000000, ), ).toBe(0); expect( await visibleCount( page, (id) => (id & 0xf0000000) === 0xc0000000, ), ).toBe(0); });