feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s

Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 21:33:53 +02:00
parent 65c0fbb87d
commit 2bd1b54936
32 changed files with 3046 additions and 63 deletions
+401
View File
@@ -0,0 +1,401 @@
// 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);
// 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<number> {
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);
});
@@ -15,6 +15,7 @@ interface DebugSnapshot {
import type {
MapCameraSnapshot,
MapFogSnapshot,
MapPickStateSnapshot,
MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte";
@@ -46,6 +47,7 @@ interface DebugSurface {
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot;
}
declare global {