Phase 29 — Map Toggles #20

Merged
developer merged 8 commits from feature/ui-map-toggles into development 2026-05-19 22:37:30 +00:00
5 changed files with 108 additions and 67 deletions
Showing only changes of commit 2528d63b51 - Show all commits
@@ -69,6 +69,7 @@ preference the store already manages.
installRendererDebugSurface, installRendererDebugSurface,
registerMapCameraProvider, registerMapCameraProvider,
registerMapFogProvider, registerMapFogProvider,
registerMapModeProvider,
registerMapPickStateProvider, registerMapPickStateProvider,
registerMapPrimitivesProvider, registerMapPrimitivesProvider,
type MapCameraSnapshot, type MapCameraSnapshot,
@@ -505,11 +506,15 @@ preference the store already manages.
const detachFog = registerMapFogProvider(() => ({ const detachFog = registerMapFogProvider(() => ({
circles: currentFogCircles.map((c) => ({ ...c })), circles: currentFogCircles.map((c) => ({ ...c })),
}) satisfies MapFogSnapshot); }) satisfies MapFogSnapshot);
const detachMode = registerMapModeProvider(() =>
handle === null ? null : handle.getMode(),
);
detachDebugProviders = (): void => { detachDebugProviders = (): void => {
detachPrim(); detachPrim();
detachPick(); detachPick();
detachCamera(); detachCamera();
detachFog(); detachFog();
detachMode();
}; };
mountedTurn = report.turn; mountedTurn = report.turn;
mountedGameId = targetGameId; mountedGameId = targetGameId;
+31 -1
View File
@@ -10,7 +10,7 @@
// lazily on every read so the returned data always reflects the // lazily on every read so the returned data always reflects the
// current frame, not the value at registration time. // current frame, not the value at registration time.
import type { Primitive, PrimitiveID } from "../map/world"; import type { Primitive, PrimitiveID, WrapMode } from "../map/world";
/** Snapshot returned by `getMapPrimitives()`. The renderer applies /** Snapshot returned by `getMapPrimitives()`. The renderer applies
* pick-mode dimming via the underlying `Graphics.alpha`, so the * pick-mode dimming via the underlying `Graphics.alpha`, so the
@@ -73,11 +73,13 @@ type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
type PickStateProvider = () => MapPickStateSnapshot; type PickStateProvider = () => MapPickStateSnapshot;
type CameraProvider = () => MapCameraSnapshot | null; type CameraProvider = () => MapCameraSnapshot | null;
type FogProvider = () => MapFogSnapshot; type FogProvider = () => MapFogSnapshot;
type ModeProvider = () => WrapMode | null;
let primitivesProvider: PrimitivesProvider | null = null; let primitivesProvider: PrimitivesProvider | null = null;
let pickStateProvider: PickStateProvider | null = null; let pickStateProvider: PickStateProvider | null = null;
let cameraProvider: CameraProvider | null = null; let cameraProvider: CameraProvider | null = null;
let fogProvider: FogProvider | null = null; let fogProvider: FogProvider | null = null;
let modeProvider: ModeProvider | null = null;
/** /**
* registerMapPrimitivesProvider attaches a provider that yields the * registerMapPrimitivesProvider attaches a provider that yields the
@@ -134,6 +136,22 @@ export function registerMapFogProvider(provider: FogProvider): () => void {
}; };
} }
/**
* registerMapModeProvider attaches a provider that yields the
* renderer's current `WrapMode` ('torus' or 'no-wrap'). Used by
* Phase 29 e2e specs to await the renderer remount after a
* wrap-mode flip — `getMapCamera()` alone is not a reliable signal
* because the same camera survives across a remount, so the spec
* watches the mode flip instead. Same idempotent semantics as the
* other providers.
*/
export function registerMapModeProvider(provider: ModeProvider): () => void {
modeProvider = provider;
return () => {
if (modeProvider === provider) modeProvider = null;
};
}
const EMPTY_PICK_STATE: MapPickStateSnapshot = { const EMPTY_PICK_STATE: MapPickStateSnapshot = {
active: false, active: false,
sourcePlanetNumber: null, sourcePlanetNumber: null,
@@ -166,12 +184,20 @@ export function getMapFog(): MapFogSnapshot {
return fogProvider?.() ?? { circles: [] }; return fogProvider?.() ?? { circles: [] };
} }
/** Pulls the renderer's current `WrapMode`. Returns `null` when no
* map view is mounted (the surface is queried during navigation or
* before the first render). */
export function getMapMode(): WrapMode | null {
return modeProvider?.() ?? null;
}
interface RendererDebugWindow { interface RendererDebugWindow {
__galaxyDebug?: { __galaxyDebug?: {
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[]; getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
getMapPickState?: () => MapPickStateSnapshot; getMapPickState?: () => MapPickStateSnapshot;
getMapCamera?: () => MapCameraSnapshot | null; getMapCamera?: () => MapCameraSnapshot | null;
getMapFog?: () => MapFogSnapshot; getMapFog?: () => MapFogSnapshot;
getMapMode?: () => WrapMode | null;
[key: string]: unknown; [key: string]: unknown;
}; };
} }
@@ -195,6 +221,7 @@ export function installRendererDebugSurface(): () => void {
getMapPickState, getMapPickState,
getMapCamera, getMapCamera,
getMapFog, getMapFog,
getMapMode,
}; };
win.__galaxyDebug = surface; win.__galaxyDebug = surface;
return (): void => { return (): void => {
@@ -215,5 +242,8 @@ export function installRendererDebugSurface(): () => void {
if (current.getMapFog === getMapFog) { if (current.getMapFog === getMapFog) {
delete current.getMapFog; delete current.getMapFog;
} }
if (current.getMapMode === getMapMode) {
delete current.getMapMode;
}
}; };
} }
@@ -10,6 +10,7 @@
import { import {
getMapCamera, getMapCamera,
getMapFog, getMapFog,
getMapMode,
getMapPickState, getMapPickState,
getMapPrimitives, getMapPrimitives,
type MapCameraSnapshot, type MapCameraSnapshot,
@@ -17,6 +18,7 @@
type MapPickStateSnapshot, type MapPickStateSnapshot,
type MapPrimitiveSnapshot, type MapPrimitiveSnapshot,
} from "../../../lib/debug-surface.svelte"; } from "../../../lib/debug-surface.svelte";
import type { WrapMode } from "../../../map/world";
interface DebugSnapshot { interface DebugSnapshot {
publicKey: number[]; publicKey: number[];
@@ -42,6 +44,7 @@
getMapPickState(): MapPickStateSnapshot; getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null; getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot; getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
} }
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface }; type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -142,6 +145,9 @@
getMapFog() { getMapFog() {
return getMapFog(); return getMapFog();
}, },
getMapMode() {
return getMapMode();
},
}; };
(window as DebugWindow).__galaxyDebug = surface; (window as DebugWindow).__galaxyDebug = surface;
ready = true; ready = true;
+64 -66
View File
@@ -221,26 +221,33 @@ interface PrimitiveLite {
async function visiblePlanets(page: Page): Promise<number[]> { async function visiblePlanets(page: Page): Promise<number[]> {
return await page.evaluate(() => { return await page.evaluate(() => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
// Planet primitive ids are the engine planet numbers (small); // Planet primitive ids are the engine planet numbers small
// every other category uses a high-bit prefix. // 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 return prims
.filter((p) => p.visible && p.id < 1_000_000) .filter((p) => p.visible && p.id > 0 && p.id < 10_000_000)
.map((p) => p.id) .map((p) => p.id)
.sort((a, b) => a - b); .sort((a, b) => a - b);
}); });
} }
async function visibleCount( async function visibleHighBitCount(
page: Page, page: Page,
predicate: (id: number) => boolean, prefix: number,
): Promise<number> { ): Promise<number> {
return await page.evaluate((pred: string) => { // Convert ids to uint32 before masking so the comparison works
const fn = new Function("id", `return (${pred})(id);`) as ( // for ids stored as signed-negative numbers (JS bitwise ops force
id: number, // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too.
) => boolean; return await page.evaluate((p: number) => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
return prims.filter((p) => p.visible && fn(p.id)).length; return prims.filter(
}, predicate.toString()); (prim) =>
prim.visible && ((prim.id >>> 0) & 0xf0000000) === (p >>> 0),
).length;
}, prefix);
} }
test("gear popover toggles a planet kind off and cascades onto its markers", async ({ test("gear popover toggles a planet kind off and cascades onto its markers", async ({
@@ -253,40 +260,30 @@ test("gear popover toggles a planet kind off and cascades onto its markers", asy
// Baseline — every planet shows up, plus the battle X-cross (2 // Baseline — every planet shows up, plus the battle X-cross (2
// LinePrim) and the bombing ring on the foreign planet. // LinePrim) and the bombing ring on the foreign planet.
expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]); expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
// Two battle marker line primitives (high-bit prefix 0xa0000000). expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2);
expect( expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1);
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 page.getByTestId("map-toggles-trigger").click();
await expect(page.getByTestId("map-toggles-surface")).toBeVisible(); await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
await page.getByTestId("map-toggles-foreign-planets").click(); await page.getByTestId("map-toggles-foreign-planets").click();
// The foreign planet (id 3) is gone — and its battle / bombing // The cascade applies asynchronously through the Svelte effect;
// markers cascaded with it. // 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 visiblePlanets(page)).toEqual([1, 2, 4, 5]);
expect( expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
await visibleCount( expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
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 ({ test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
@@ -308,17 +305,17 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visibility-fog").click(); await page.getByTestId("map-toggles-visibility-fog").click();
const offFog = await page.evaluate( // The effect re-run is async; wait for the fog payload to clear
() => window.__galaxyDebug!.getMapFog!().circles, // instead of reading it on the next tick.
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
); );
expect(offFog).toEqual([]);
// Toggling back on rebuilds the fog circles for the same planets. // Toggling back on rebuilds the fog circles for the same planets.
await page.getByTestId("map-toggles-visibility-fog").click(); await page.getByTestId("map-toggles-visibility-fog").click();
const onAgain = await page.evaluate( await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles, () => window.__galaxyDebug!.getMapFog!().circles.length === 2,
); );
expect(onAgain.length).toBe(2);
}); });
test("wrap mode radios flip the renderer and the camera centre survives", async ({ test("wrap mode radios flip the renderer and the camera centre survives", async ({
@@ -328,6 +325,10 @@ test("wrap mode radios flip the renderer and the camera centre survives", async
await bootSession(page); await bootSession(page);
await openGame(page); await openGame(page);
// Confirm the renderer starts in torus mode.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "torus",
);
const initial = await page.evaluate(() => const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(), window.__galaxyDebug!.getMapCamera!(),
); );
@@ -337,19 +338,26 @@ test("wrap mode radios flip the renderer and the camera centre survives", async
await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click(); await page.getByTestId("map-toggles-wrap-no-wrap").click();
// Mount path is async (Pixi re-init takes a frame). Wait for the // `setWrapMode` triggers a full Pixi remount; wait for the
// camera reading to settle into the new mount and assert the // renderer to settle into the new mode and the debug surface to
// centre is within 1 px of the pre-toggle value. // re-register before reading the camera. The mode provider is
await page.waitForFunction(() => { // re-bound inside `runSerializedMount` after `createRenderer`
const c = window.__galaxyDebug?.getMapCamera?.(); // resolves, so observing `getMapMode() === "no-wrap"` is the
return c !== null && c !== undefined && c.camera.centerX !== undefined; // canonical "remount complete" signal.
}); await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
);
const after = await page.evaluate(() => const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(), window.__galaxyDebug!.getMapCamera!(),
); );
expect(after).not.toBeNull(); expect(after).not.toBeNull();
expect(Math.abs(after!.camera.centerX - startCentre.centerX)).toBeLessThanOrEqual(1); expect(
expect(Math.abs(after!.camera.centerY - startCentre.centerY)).toBeLessThanOrEqual(1); 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 }) => { test("toggle state persists across a page reload", async ({ page }) => {
@@ -386,16 +394,6 @@ test("toggle state persists across a page reload", async ({ page }) => {
await page.getByTestId("map-toggles-bombing-markers").isChecked(), await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false); ).toBe(false);
// Battle X-cross and bombing ring are hidden in the renderer. // Battle X-cross and bombing ring are hidden in the renderer.
expect( expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
await visibleCount( expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
page,
(id) => (id & 0xf0000000) === 0xa0000000,
),
).toBe(0);
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xc0000000,
),
).toBe(0);
}); });
@@ -19,6 +19,7 @@ import type {
MapPickStateSnapshot, MapPickStateSnapshot,
MapPrimitiveSnapshot, MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte"; } from "../../src/lib/debug-surface.svelte";
import type { WrapMode } from "../../src/map/world";
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. // Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`, // Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
@@ -48,6 +49,7 @@ interface DebugSurface {
getMapPickState(): MapPickStateSnapshot; getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null; getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot; getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
} }
declare global { declare global {