Phase 29 — Map Toggles #20
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user