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,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user