680ebac919
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the renderer divides by the current camera scale on every `viewport.zoomed` so thin lines / small markers stay the same on-screen size at any zoom. * Known-size planets switch to `pointRadiusWorld`, softened against the reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified planets pin to a 3-px disc. * New planet label layer renders a two-line `name / #N` legend under each planet (`#N` only for unidentified or when the new `planetNames` toggle is off). Selection now paints an inverse-fill frame around the selected planet's label plus an outline on the disc; the old selection-ring primitive is retired. * Bombing markers swap the separate CirclePrim for a planet-outline overlay (damaged / wiped colour); the report deep-link moves to a "view bombing report" link in the planet inspector. * Docs + tests follow: `renderer.md` reflects the new sizing contract + label / outline layers, vitest covers the sizing math, label formatting, and the new toggle, and the map-toggles e2e adds a persistence case for `planetNames`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
504 lines
16 KiB
TypeScript
504 lines
16 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.
|
|
// * `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<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(
|
|
"**/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<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("/");
|
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
|
await page.evaluate(
|
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
|
GAME_ID,
|
|
);
|
|
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("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);
|
|
|
|
// The restored `game` screen re-stamps history via shallow routing
|
|
// on first render; wait only for the navigation to commit so that
|
|
// `pushState` does not abort a default `reload()` (which waits for
|
|
// `load`).
|
|
await page.reload({ waitUntil: "commit" });
|
|
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 primitives stay hidden in the renderer. F8-12 / #30
|
|
// retired the bombing CirclePrim — the toggle now hides a planet
|
|
// outline overlay, which sits outside the primitive surface; the
|
|
// high-bit 0xc… range is permanently empty.
|
|
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
|
});
|
|
|
|
test("planet-names toggle persists across a page reload (F8-12 / #29)", async ({
|
|
page,
|
|
}) => {
|
|
await mockGateway(page, { currentTurn: 1 });
|
|
await bootSession(page);
|
|
await openGame(page);
|
|
|
|
await page.getByTestId("map-toggles-trigger").click();
|
|
// Default ON; flip it OFF.
|
|
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
|
true,
|
|
);
|
|
await page.getByTestId("map-toggles-planet-names").click();
|
|
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
|
false,
|
|
);
|
|
|
|
await page.reload({ waitUntil: "commit" });
|
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
|
"data-status",
|
|
"ready",
|
|
);
|
|
await page.getByTestId("map-toggles-trigger").click();
|
|
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
// 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<number> {
|
|
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);
|
|
});
|