feat(ui): Phase 29 map visibility toggles
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user