Files
galaxy-game/ui/frontend/tests/e2e/map-toggles.spec.ts
T
Ilia Denisov 37580b7699
Tests · UI / test (push) Waiting to run
fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.

Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.

Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:39:39 +02:00

403 lines
12 KiB
TypeScript

// 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<void> {
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<void>(() => {});
},
);
}
async function bootSession(page: Page): Promise<void> {
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<void> {
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<number[]> {
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<number> {
// 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);
});