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 {
|
||||
|
||||
@@ -280,3 +280,56 @@ describe("hitTest — empty results and scale", () => {
|
||||
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hitTest — Phase 29 hiddenIds parameter", () => {
|
||||
const cam = camAt(500, 500);
|
||||
test("a hidden primitive is skipped entirely", () => {
|
||||
const w = new World(1000, 1000, [
|
||||
point(1, 500, 500),
|
||||
point(2, 500, 500, { priority: -1 }),
|
||||
]);
|
||||
// Without filtering, primitive 1 wins (higher priority).
|
||||
expect(hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus")?.primitive.id)
|
||||
.toBe(1);
|
||||
// With 1 hidden, the cursor falls through to primitive 2.
|
||||
expect(
|
||||
hitTest(
|
||||
w,
|
||||
cam,
|
||||
VP,
|
||||
cursorOver(500, 500, cam),
|
||||
"torus",
|
||||
new Set([1]),
|
||||
)?.primitive.id,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("hiding every match returns null", () => {
|
||||
const w = new World(1000, 1000, [point(1, 500, 500)]);
|
||||
expect(
|
||||
hitTest(
|
||||
w,
|
||||
cam,
|
||||
VP,
|
||||
cursorOver(500, 500, cam),
|
||||
"torus",
|
||||
new Set([1]),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty hidden set is equivalent to omitting the parameter", () => {
|
||||
const w = new World(1000, 1000, [point(1, 500, 500)]);
|
||||
const a = hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus");
|
||||
const b = hitTest(
|
||||
w,
|
||||
cam,
|
||||
VP,
|
||||
cursorOver(500, 500, cam),
|
||||
"torus",
|
||||
new Set(),
|
||||
);
|
||||
expect(a?.primitive.id).toBe(1);
|
||||
expect(b?.primitive.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
distSqPointToSegment,
|
||||
screenToWorld,
|
||||
torusShortestDelta,
|
||||
torusShortestDistance,
|
||||
worldToScreen,
|
||||
} from "../src/map/math";
|
||||
|
||||
@@ -104,3 +105,22 @@ describe("screenToWorld and worldToScreen", () => {
|
||||
expect(w1.x - w0.x).toBeCloseTo(1, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("torusShortestDistance", () => {
|
||||
test("returns plain Euclidean distance when no wrap is shorter", () => {
|
||||
const d = torusShortestDistance(0, 0, 3, 4, 1000, 1000);
|
||||
expect(d).toBeCloseTo(5, 12);
|
||||
});
|
||||
|
||||
test("respects torus wrap on both axes", () => {
|
||||
// Wrap on x: 50 → 950 across the seam is 100 units.
|
||||
// Wrap on y: 100 → 900 across the seam is 200 units.
|
||||
// Hypot(100, 200) ≈ 223.606.
|
||||
const d = torusShortestDistance(50, 100, 950, 900, 1000, 1000);
|
||||
expect(d).toBeCloseTo(Math.hypot(100, 200), 9);
|
||||
});
|
||||
|
||||
test("zero when both points coincide", () => {
|
||||
expect(torusShortestDistance(123, 456, 123, 456, 1000, 1000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
|
||||
// The popover is a thin view of the `GameStateStore` runes —
|
||||
// every control fires `setMapToggle` / `setWrapMode` on the store
|
||||
// and reads the current state through `store.mapToggles` /
|
||||
// `store.wrapMode`. The tests assert the wiring, the default
|
||||
// rendering, and the popover lifecycle (open / Escape close).
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import MapTogglesControl from "../src/lib/active-view/map-toggles.svelte";
|
||||
import {
|
||||
DEFAULT_MAP_TOGGLES,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
|
||||
function buildStore(): GameStateStore {
|
||||
const store = new GameStateStore();
|
||||
store.status = "ready";
|
||||
store.wrapMode = "torus";
|
||||
store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
return store;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
describe("MapTogglesControl", () => {
|
||||
test("trigger renders and the popover is closed by default", () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
const trigger = ui.getByTestId("map-toggles-trigger");
|
||||
expect(trigger).toBeInTheDocument();
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
// The 44+ px touch-target is enforced through CSS; jsdom does
|
||||
// not parse scoped Svelte styles for `getComputedStyle`, so the
|
||||
// dimension is verified in the Playwright e2e where real
|
||||
// browsers compute the rule.
|
||||
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
|
||||
});
|
||||
|
||||
test("clicking the trigger opens the popover with defaults applied", async () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
const surface = ui.getByTestId("map-toggles-surface");
|
||||
expect(surface).toBeInTheDocument();
|
||||
expect(ui.getByTestId("map-toggles-hyperspace-groups")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-incoming-groups")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unidentified-groups")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-cargo-routes")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-battle-markers")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-bombing-markers")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-foreign-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-visibility-fog")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("flipping a checkbox calls setMapToggle with the new value", async () => {
|
||||
const store = buildStore();
|
||||
const setMapToggle = vi
|
||||
.spyOn(store, "setMapToggle")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
|
||||
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-foreign-planets"));
|
||||
expect(setMapToggle).toHaveBeenCalledWith("foreignPlanets", false);
|
||||
});
|
||||
|
||||
test("battle and bombing toggles are independent", async () => {
|
||||
const store = buildStore();
|
||||
const setMapToggle = vi
|
||||
.spyOn(store, "setMapToggle")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
|
||||
expect(setMapToggle).toHaveBeenCalledTimes(1);
|
||||
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
|
||||
// No spillover into bombingMarkers.
|
||||
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
|
||||
});
|
||||
|
||||
test("selecting the no-wrap radio calls setWrapMode", async () => {
|
||||
const store = buildStore();
|
||||
const setWrapMode = vi
|
||||
.spyOn(store, "setWrapMode")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
|
||||
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
|
||||
});
|
||||
|
||||
test("Escape closes the popover", async () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
|
||||
await fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
|
||||
});
|
||||
|
||||
test("clicking outside the popover closes it", async () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
|
||||
// Synthetic outside click — fire on document with the trigger
|
||||
// removed from the click target chain.
|
||||
await fireEvent.click(document.body);
|
||||
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// Phase 29 persistence + new-turn reset coverage for the
|
||||
// `GameStateStore.mapToggles` rune. The tests drive the store
|
||||
// against a real `fake-indexeddb`-backed Cache (the same harness
|
||||
// `game-state.test.ts` uses) so the JSON-blob round-trip and the
|
||||
// stale-`lastResetTurn` branch are exercised end-to-end.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { Builder } from "flatbuffers";
|
||||
|
||||
import {
|
||||
DEFAULT_MAP_TOGGLES,
|
||||
GameStateStore,
|
||||
type MapToggles,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { Report } from "../src/proto/galaxy/fbs/report";
|
||||
|
||||
const listMyGamesSpy = vi.fn();
|
||||
vi.mock("../src/api/lobby", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
|
||||
"../src/api/lobby",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-map-toggles-test-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
listMyGamesSpy.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function makeGameSummary(currentTurn: number) {
|
||||
return {
|
||||
gameId: GAME_ID,
|
||||
gameName: "Test Game",
|
||||
gameType: "private",
|
||||
status: "running",
|
||||
ownerUserId: "owner-1",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
enrollmentEndsAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentTurn,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReportPayload(turn: number): Uint8Array {
|
||||
const builder = new Builder(64);
|
||||
Report.startReport(builder);
|
||||
Report.addTurn(builder, BigInt(turn));
|
||||
Report.addWidth(builder, 4000);
|
||||
Report.addHeight(builder, 4000);
|
||||
Report.addPlanetCount(builder, 0);
|
||||
builder.finish(Report.endReport(builder));
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
function makeFakeClient(turn: number): GalaxyClient {
|
||||
return {
|
||||
executeCommand: async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload(turn),
|
||||
}),
|
||||
} as unknown as GalaxyClient;
|
||||
}
|
||||
|
||||
describe("GameStateStore.mapToggles persistence", () => {
|
||||
test("defaults apply when no blob is persisted", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("setMapToggle round-trips through Cache across instances", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
|
||||
const a = new GameStateStore();
|
||||
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||
await a.setMapToggle("hyperspaceGroups", false);
|
||||
await a.setMapToggle("battleMarkers", false);
|
||||
await a.setMapToggle("visibilityFog", false);
|
||||
a.dispose();
|
||||
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
|
||||
const b = new GameStateStore();
|
||||
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||
expect(b.mapToggles.hyperspaceGroups).toBe(false);
|
||||
expect(b.mapToggles.battleMarkers).toBe(false);
|
||||
expect(b.mapToggles.visibilityFog).toBe(false);
|
||||
// Untouched flags retain defaults.
|
||||
expect(b.mapToggles.bombingMarkers).toBe(true);
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
test("missing fields in a persisted blob fall back to defaults", async () => {
|
||||
// Simulate an older client persisting a partial blob — only
|
||||
// `hyperspaceGroups` is set, every other field must inherit
|
||||
// the current default.
|
||||
await cache.put("game-map-toggles", GAME_ID, {
|
||||
toggles: { hyperspaceGroups: false } as Partial<MapToggles>,
|
||||
lastResetTurn: 5,
|
||||
});
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
|
||||
expect(store.mapToggles.hyperspaceGroups).toBe(false);
|
||||
expect(store.mapToggles.battleMarkers).toBe(true);
|
||||
expect(store.mapToggles.bombingMarkers).toBe(true);
|
||||
expect(store.mapToggles.visibilityFog).toBe(true);
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameStateStore.mapToggles new-turn reset", () => {
|
||||
test("a server turn newer than lastResetTurn resets every flag", async () => {
|
||||
await cache.put("game-map-toggles", GAME_ID, {
|
||||
toggles: {
|
||||
...DEFAULT_MAP_TOGGLES,
|
||||
hyperspaceGroups: false,
|
||||
battleMarkers: false,
|
||||
visibilityFog: false,
|
||||
},
|
||||
lastResetTurn: 4,
|
||||
});
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
|
||||
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
|
||||
// The reset write back to cache so a subsequent reload sees the
|
||||
// fresh state.
|
||||
const persisted = await cache.get<{
|
||||
toggles: MapToggles;
|
||||
lastResetTurn: number;
|
||||
}>("game-map-toggles", GAME_ID);
|
||||
expect(persisted?.toggles).toEqual(DEFAULT_MAP_TOGGLES);
|
||||
expect(persisted?.lastResetTurn).toBe(5);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("matching lastResetTurn restores persisted overrides", async () => {
|
||||
await cache.put("game-map-toggles", GAME_ID, {
|
||||
toggles: { ...DEFAULT_MAP_TOGGLES, hyperspaceGroups: false },
|
||||
lastResetTurn: 5,
|
||||
});
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
|
||||
expect(store.mapToggles.hyperspaceGroups).toBe(false);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("advanceToPending resets toggles after jumping onto the new turn", async () => {
|
||||
await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 4);
|
||||
await cache.put("game-map-toggles", GAME_ID, {
|
||||
toggles: DEFAULT_MAP_TOGGLES,
|
||||
lastResetTurn: 4,
|
||||
});
|
||||
// First setGame opens the user on turn 4 with currentTurn=5
|
||||
// (last-viewed-turn bookmark < currentTurn). The new-turn
|
||||
// reset path fires immediately because lastResetTurn=4 < 5.
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client: makeFakeClient(4), cache, gameId: GAME_ID });
|
||||
// Drift the toggles after the setGame reset so we can verify
|
||||
// that advanceToPending resets them again on the user's
|
||||
// explicit jump onto turn 5.
|
||||
await store.setMapToggle("battleMarkers", false);
|
||||
expect(store.mapToggles.battleMarkers).toBe(false);
|
||||
expect(store.pendingTurn).toBe(5);
|
||||
|
||||
// User clicks "Return to current turn" — the store fetches the
|
||||
// turn-5 report and resets toggles.
|
||||
await store.advanceToPending();
|
||||
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
|
||||
expect(store.pendingTurn).toBeNull();
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
// Phase 29 coverage for the categories + planetDependents maps that
|
||||
// `reportToWorld` now returns. The map view consumes both to feed
|
||||
// `RendererHandle.setHiddenPrimitiveIds`: categories drive the
|
||||
// per-category toggle, planetDependents drive the cascade (planet
|
||||
// hidden → markers + in-space + incoming groups flying to it hide
|
||||
// together).
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type {
|
||||
GameReport,
|
||||
ReportBattle,
|
||||
ReportBombing,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalShipGroup,
|
||||
ReportOtherShipGroup,
|
||||
ReportPlanet,
|
||||
ReportUnidentifiedShipGroup,
|
||||
} from "../src/api/game-state";
|
||||
import { BATTLE_MARKER_ID_PREFIX, BOMBING_MARKER_ID_PREFIX } from "../src/map/battle-markers";
|
||||
import { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
|
||||
import { reportToWorld } from "../src/map/state-binding";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||
return {
|
||||
number: 0,
|
||||
name: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLocalShipGroup(
|
||||
overrides: Partial<ReportLocalShipGroup>,
|
||||
): ReportLocalShipGroup {
|
||||
return {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
count: 1,
|
||||
class: "Scout",
|
||||
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
||||
cargo: "NONE",
|
||||
load: 0,
|
||||
destination: 0,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 1,
|
||||
mass: 0,
|
||||
state: "InOrbit",
|
||||
fleet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOtherShipGroup(
|
||||
overrides: Partial<ReportOtherShipGroup>,
|
||||
): ReportOtherShipGroup {
|
||||
return {
|
||||
count: 1,
|
||||
class: "Cruiser",
|
||||
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
||||
cargo: "NONE",
|
||||
load: 0,
|
||||
destination: 0,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 1,
|
||||
mass: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIncoming(
|
||||
overrides: Partial<ReportIncomingShipGroup>,
|
||||
): ReportIncomingShipGroup {
|
||||
return {
|
||||
origin: 0,
|
||||
destination: 0,
|
||||
distance: 0,
|
||||
speed: 1,
|
||||
mass: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnidentified(
|
||||
overrides: Partial<ReportUnidentifiedShipGroup>,
|
||||
): ReportUnidentifiedShipGroup {
|
||||
return { x: 0, y: 0, ...overrides };
|
||||
}
|
||||
|
||||
function makeBattle(overrides: Partial<ReportBattle>): ReportBattle {
|
||||
return {
|
||||
id: "battle",
|
||||
planet: 0,
|
||||
shots: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBombing(overrides: Partial<ReportBombing>): ReportBombing {
|
||||
return {
|
||||
planetNumber: 0,
|
||||
planet: "",
|
||||
owner: "",
|
||||
attacker: "",
|
||||
production: "",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 0,
|
||||
wiped: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reportToWorld — categories", () => {
|
||||
test("planet primitives carry their kind-flavoured category", () => {
|
||||
const { categories } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 300 }),
|
||||
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 400 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(categories.get(1)).toBe("planet-local");
|
||||
expect(categories.get(2)).toBe("planet-foreign");
|
||||
expect(categories.get(3)).toBe("planet-uninhabited");
|
||||
expect(categories.get(4)).toBe("planet-unidentified");
|
||||
});
|
||||
|
||||
test("ship-group sub-builder tags every primitive id", () => {
|
||||
const localGroupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
|
||||
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
|
||||
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
|
||||
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
|
||||
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
|
||||
const unidentifiedId = SHIP_GROUP_ID_OFFSETS.unidentified + 0;
|
||||
const { categories } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
localShipGroups: [
|
||||
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
|
||||
],
|
||||
otherShipGroups: [
|
||||
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
|
||||
],
|
||||
incomingShipGroups: [
|
||||
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
|
||||
],
|
||||
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
|
||||
}),
|
||||
);
|
||||
expect(categories.get(localGroupPrimId)).toBe("hyperspaceGroup");
|
||||
expect(categories.get(localLineId)).toBe("hyperspaceGroup");
|
||||
expect(categories.get(otherId)).toBe("hyperspaceGroup");
|
||||
expect(categories.get(incomingPointId)).toBe("incomingGroup");
|
||||
expect(categories.get(incomingLineId)).toBe("incomingGroup");
|
||||
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
|
||||
});
|
||||
|
||||
test("battle markers and bombing markers each carry their own category", () => {
|
||||
const { categories } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
battles: [makeBattle({ id: "b1", planet: 2 })],
|
||||
bombings: [makeBombing({ planetNumber: 2 })],
|
||||
}),
|
||||
);
|
||||
// Battle marker emits two LinePrims at `BATTLE_MARKER_ID_PREFIX | (i << 4) | (A|B)`.
|
||||
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
|
||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||
expect(categories.get(battleA)).toBe("battleMarker");
|
||||
expect(categories.get(battleB)).toBe("battleMarker");
|
||||
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
|
||||
expect(categories.get(bombingId)).toBe("bombingMarker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportToWorld — planetDependents", () => {
|
||||
test("every planet seeds its own dependents entry with its own id", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 7, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(planetDependents.get(1)?.has(1)).toBe(true);
|
||||
expect(planetDependents.get(7)?.has(7)).toBe(true);
|
||||
});
|
||||
|
||||
test("battle / bombing markers cascade onto their anchor planet", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
battles: [makeBattle({ planet: 2 })],
|
||||
bombings: [makeBombing({ planetNumber: 2 })],
|
||||
}),
|
||||
);
|
||||
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
|
||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
|
||||
const deps = planetDependents.get(2) ?? new Set();
|
||||
expect(deps.has(2)).toBe(true);
|
||||
expect(deps.has(battleA)).toBe(true);
|
||||
expect(deps.has(battleB)).toBe(true);
|
||||
expect(deps.has(bombingId)).toBe(true);
|
||||
});
|
||||
|
||||
test("in-space groups cascade onto their destination planet", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
localShipGroups: [
|
||||
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
|
||||
],
|
||||
otherShipGroups: [
|
||||
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const localPointId = SHIP_GROUP_ID_OFFSETS.local + 0;
|
||||
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
|
||||
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
|
||||
const deps = planetDependents.get(2) ?? new Set();
|
||||
expect(deps.has(localPointId)).toBe(true);
|
||||
expect(deps.has(localLineId)).toBe(true);
|
||||
expect(deps.has(otherId)).toBe(true);
|
||||
});
|
||||
|
||||
test("incoming groups cascade onto their destination planet", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
incomingShipGroups: [
|
||||
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
|
||||
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
|
||||
const deps = planetDependents.get(2) ?? new Set();
|
||||
expect(deps.has(incomingPointId)).toBe(true);
|
||||
expect(deps.has(incomingLineId)).toBe(true);
|
||||
});
|
||||
|
||||
test("unidentified groups do not contribute to any planet's dependents", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
|
||||
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
|
||||
}),
|
||||
);
|
||||
// Only the local planet seeds its own entry; no other entries.
|
||||
expect(planetDependents.size).toBe(1);
|
||||
expect(planetDependents.get(1)?.size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
// Phase 29 pure helpers in `src/map/visibility.ts`. The tests exercise
|
||||
// `computeHiddenPlanetNumbers`, `computeHiddenIds`, `computeFogCircles`,
|
||||
// and `isCategoryVisible` directly so the map view can stay a thin
|
||||
// wiring layer.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||
import { DEFAULT_MAP_TOGGLES, type MapToggles } from "../src/lib/game-state.svelte";
|
||||
import type { MapCategory } from "../src/map/state-binding";
|
||||
import {
|
||||
FLIGHT_DISTANCE_PER_DRIVE,
|
||||
VISIBILITY_DISTANCE_PER_DRIVE,
|
||||
computeFogCircles,
|
||||
computeHiddenIds,
|
||||
computeHiddenPlanetNumbers,
|
||||
fingerprintHiddenPlanets,
|
||||
isCategoryVisible,
|
||||
} from "../src/map/visibility";
|
||||
import type { PrimitiveID } from "../src/map/world";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||
return {
|
||||
number: 0,
|
||||
name: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toggles(overrides: Partial<MapToggles> = {}): MapToggles {
|
||||
return { ...DEFAULT_MAP_TOGGLES, ...overrides };
|
||||
}
|
||||
|
||||
describe("isCategoryVisible", () => {
|
||||
test("local planets are always visible regardless of toggles", () => {
|
||||
expect(
|
||||
isCategoryVisible("planet-local", toggles({ foreignPlanets: false })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("each kind toggle controls its planet category", () => {
|
||||
const t = toggles({
|
||||
foreignPlanets: false,
|
||||
uninhabitedPlanets: false,
|
||||
unidentifiedPlanets: false,
|
||||
});
|
||||
expect(isCategoryVisible("planet-foreign", t)).toBe(false);
|
||||
expect(isCategoryVisible("planet-uninhabited", t)).toBe(false);
|
||||
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
|
||||
});
|
||||
|
||||
test("battle and bombing markers have independent toggles", () => {
|
||||
const t = toggles({ battleMarkers: false, bombingMarkers: true });
|
||||
expect(isCategoryVisible("battleMarker", t)).toBe(false);
|
||||
expect(isCategoryVisible("bombingMarker", t)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeHiddenPlanetNumbers", () => {
|
||||
test("returns an empty set when defaults are in effect", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
|
||||
],
|
||||
});
|
||||
expect(computeHiddenPlanetNumbers(report, toggles())).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("kind-toggle off hides every planet of that kind", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
|
||||
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 100 }),
|
||||
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 100 }),
|
||||
],
|
||||
});
|
||||
const hidden = computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ foreignPlanets: false, unidentifiedPlanets: false }),
|
||||
);
|
||||
expect(hidden).toEqual(new Set([2, 4]));
|
||||
});
|
||||
|
||||
test("unreachablePlanets=off hides planets beyond FlightDistance", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
// Foreign within reach: distance ≈ 100 < 400.
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
|
||||
// Foreign beyond reach: distance ≈ 500 > 400.
|
||||
makePlanet({ number: 3, kind: "other", x: 600, y: 100 }),
|
||||
],
|
||||
});
|
||||
const reachLimit = 10 * FLIGHT_DISTANCE_PER_DRIVE;
|
||||
expect(reachLimit).toBe(400);
|
||||
const hidden = computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ unreachablePlanets: false }),
|
||||
);
|
||||
expect(hidden).toEqual(new Set([3]));
|
||||
});
|
||||
|
||||
test("torus wrap shortens reach distance across the seam", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 50, y: 500 }),
|
||||
// Wrap distance is 100 (50 → 950 via the left seam), well
|
||||
// inside the 400-unit reach. Without the torus metric this
|
||||
// would resolve to 900 and the planet would hide.
|
||||
makePlanet({ number: 2, kind: "other", x: 950, y: 500 }),
|
||||
],
|
||||
});
|
||||
const hidden = computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ unreachablePlanets: false }),
|
||||
);
|
||||
expect(hidden).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("localPlayerDrive=0 hides every non-local planet when reach filter is on", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 0,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "other", x: 101, y: 100 }),
|
||||
],
|
||||
});
|
||||
const hidden = computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ unreachablePlanets: false }),
|
||||
);
|
||||
expect(hidden).toEqual(new Set([2]));
|
||||
});
|
||||
|
||||
test("a report with no LOCAL planets keeps everything visible (no reach anchor)", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [makePlanet({ number: 9, kind: "other", x: 9000, y: 9000 })],
|
||||
});
|
||||
const hidden = computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ unreachablePlanets: false }),
|
||||
);
|
||||
expect(hidden).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("LOCAL planets are never hidden", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [makePlanet({ number: 1, kind: "local", x: 1, y: 1 })],
|
||||
});
|
||||
expect(
|
||||
computeHiddenPlanetNumbers(
|
||||
report,
|
||||
toggles({ foreignPlanets: false, unreachablePlanets: false }),
|
||||
),
|
||||
).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeHiddenIds", () => {
|
||||
const categories: Map<PrimitiveID, MapCategory> = new Map<
|
||||
PrimitiveID,
|
||||
MapCategory
|
||||
>([
|
||||
[1, "planet-local"],
|
||||
[2, "planet-foreign"],
|
||||
[100, "hyperspaceGroup"],
|
||||
[150, "hyperspaceGroup"],
|
||||
[200, "incomingGroup"],
|
||||
[300, "battleMarker"],
|
||||
[400, "bombingMarker"],
|
||||
]);
|
||||
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
|
||||
[1, new Set([1])],
|
||||
[2, new Set([2, 100, 150, 200, 300, 400])],
|
||||
]);
|
||||
|
||||
test("category-toggle off hides every primitive in that category", () => {
|
||||
const hidden = computeHiddenIds(
|
||||
categories,
|
||||
planetDependents,
|
||||
new Set(),
|
||||
toggles({ hyperspaceGroups: false }),
|
||||
);
|
||||
expect(hidden.has(100)).toBe(true);
|
||||
expect(hidden.has(150)).toBe(true);
|
||||
expect(hidden.has(200)).toBe(false);
|
||||
expect(hidden.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
test("hiding a planet cascades onto its dependent primitives", () => {
|
||||
const hidden = computeHiddenIds(
|
||||
categories,
|
||||
planetDependents,
|
||||
new Set([2]),
|
||||
toggles(),
|
||||
);
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
|
||||
});
|
||||
|
||||
test("battle / bombing markers have independent toggles", () => {
|
||||
const hidden = computeHiddenIds(
|
||||
categories,
|
||||
planetDependents,
|
||||
new Set(),
|
||||
toggles({ battleMarkers: false }),
|
||||
);
|
||||
expect(hidden.has(300)).toBe(true);
|
||||
expect(hidden.has(400)).toBe(false);
|
||||
});
|
||||
|
||||
test("planet cascade and category toggle compose without duplicates", () => {
|
||||
const hidden = computeHiddenIds(
|
||||
categories,
|
||||
planetDependents,
|
||||
new Set([2]),
|
||||
toggles({ battleMarkers: false }),
|
||||
);
|
||||
// 300 is already present from the cascade; the category toggle
|
||||
// re-adds it but Set semantics dedupe.
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFogCircles", () => {
|
||||
test("disabled toggle returns an empty list", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
|
||||
});
|
||||
expect(
|
||||
computeFogCircles(report, toggles({ visibilityFog: false })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("zero drive returns an empty list (radius would be zero)", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 0,
|
||||
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
|
||||
});
|
||||
expect(computeFogCircles(report, toggles())).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits one circle per LOCAL planet at VisibilityDistance", () => {
|
||||
const report = makeReport({
|
||||
localPlayerDrive: 10,
|
||||
planets: [
|
||||
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, kind: "local", x: 300, y: 200 }),
|
||||
makePlanet({ number: 3, kind: "other", x: 500, y: 500 }),
|
||||
],
|
||||
});
|
||||
const radius = 10 * VISIBILITY_DISTANCE_PER_DRIVE;
|
||||
expect(radius).toBe(300);
|
||||
expect(computeFogCircles(report, toggles())).toEqual([
|
||||
{ x: 100, y: 100, radius },
|
||||
{ x: 300, y: 200, radius },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fingerprintHiddenPlanets", () => {
|
||||
test("sorts numerically for deterministic fingerprint", () => {
|
||||
expect(fingerprintHiddenPlanets(new Set([3, 1, 2]))).toBe("1,2,3");
|
||||
});
|
||||
|
||||
test("empty set returns an empty string", () => {
|
||||
expect(fingerprintHiddenPlanets(new Set())).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user