ui/phase-11: map wired to live game state

Replaces the Phase 10 map stub with live planet rendering driven by
`user.games.report`, and wires the header turn counter to the same
data. Phase 11's frontend sits on a per-game `GameStateStore` that
lives in `lib/game-state.svelte.ts`: the in-game shell layout
instantiates one per game, exposes it through Svelte context, and
disposes it on remount. The store discovers the game's current turn
through `lobby.my.games.list`, fetches the matching report, and
exposes a TS-friendly snapshot to the header turn counter, the map
view, and the inspector / order / calculator tabs that later phases
will plug onto the same instance.

The pipeline forced one cross-stage decision: the user surface needs
the current turn number to know which report to fetch, but
`GameSummary` did not expose it. Phase 11 extends the lobby
catalogue (FB schema, transcoder, Go model, backend
gameSummaryWire, gateway decoders, openapi, TS bindings,
api/lobby.ts) with `current_turn:int32`. The data was already
tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is
a wire change only. Two alternatives were rejected: a brand-new
`user.games.state` message (full wire-flow for one field) and
hard-coding `turn=0` (works for the dev sandbox, which never
advances past zero, but renders the initial state for any real
game). The change crosses Phase 8's already-shipped catalogue per
the project's "decisions baked back into the live plan" rule —
existing tests and fixtures are updated in the same patch.

The state binding lives in `map/state-binding.ts::reportToWorld`:
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours,
fill alphas, and point radii so the user can tell them apart at a
glance. The planet engine number is reused as the primitive id so
a hit-test result resolves directly to a planet without an extra
lookup table. Zero-planet reports yield a well-formed empty world;
malformed dimensions fall back to 1×1 so a bad report cannot crash
the renderer.

The map view's mount effect creates the renderer once and skips
re-mount on no-op refreshes (same turn, same wrap mode); a turn
change or wrap-mode flip disposes and recreates it. The renderer's
external API does not yet expose `setWorld`; Phase 24 / 34 will
extract it once high-frequency updates land. The store installs a
`visibilitychange` listener that calls `refresh()` when the tab
regains focus.

Wrap-mode preference uses `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, default `torus`. Phase 11 reads through
`store.wrapMode`; Phase 29 wires the toggle UI on top of
`setWrapMode`.

Tests: Vitest unit coverage for `reportToWorld` (every kind,
ids, styling, empty / zero-dimension edges, priority order) and
for the store lifecycle (init success, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence across
instances, `failBootstrap`). Playwright e2e mocks the gateway for
`lobby.my.games.list` and `user.games.report` and asserts the
live data path: turn counter shows the reported turn,
`active-view-map` flips to `data-status="ready"`, and
`data-planet-count` matches the fixture count. The zero-planet
regression and the missing-membership error path are covered.

Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 21:17:17 +02:00
parent ff524fabc6
commit ce7a66b3e6
53 changed files with 5994 additions and 70 deletions
@@ -35,6 +35,7 @@ export interface GameFixture {
enrollmentEndsAtMs?: bigint;
createdAtMs?: bigint;
updatedAtMs?: bigint;
currentTurn?: number;
}
export interface ApplicationFixture {
@@ -79,6 +80,7 @@ function encodeGame(builder: Builder, game: GameFixture): number {
GameSummary.addEnrollmentEndsAtMs(builder, game.enrollmentEndsAtMs ?? DEFAULT_TIME_MS);
GameSummary.addCreatedAtMs(builder, game.createdAtMs ?? DEFAULT_TIME_MS);
GameSummary.addUpdatedAtMs(builder, game.updatedAtMs ?? DEFAULT_TIME_MS);
GameSummary.addCurrentTurn(builder, game.currentTurn ?? 0);
return GameSummary.endGameSummary(builder);
}
@@ -0,0 +1,129 @@
// Phase 11 helpers for forging FlatBuffers report payloads in e2e
// tests. Mirrors the engine's `report.Report` shape so the mocked
// gateway can return realistic data without standing up the real
// engine container.
//
// Phase 11 only renders planets, so the helpers keep the report shape
// minimal (turn / dimensions / planet vectors). Later phases extend
// the helper as ships, fleets, sciences, etc. land.
import { Builder } from "flatbuffers";
import {
LocalPlanet,
OtherPlanet,
Report,
UnidentifiedPlanet,
UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report";
export interface PlanetFixture {
number: number;
name: string;
x: number;
y: number;
}
export interface OtherPlanetFixture extends PlanetFixture {
owner: string;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
mapHeight?: number;
localPlanets?: PlanetFixture[];
otherPlanets?: OtherPlanetFixture[];
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
const builder = new Builder(512);
const localOffsets = (fixture.localPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
LocalPlanet.startLocalPlanet(builder);
LocalPlanet.addNumber(builder, BigInt(planet.number));
LocalPlanet.addX(builder, planet.x);
LocalPlanet.addY(builder, planet.y);
LocalPlanet.addName(builder, name);
LocalPlanet.addSize(builder, 10);
LocalPlanet.addResources(builder, 0.5);
LocalPlanet.addPopulation(builder, 0);
LocalPlanet.addIndustry(builder, 0);
return LocalPlanet.endLocalPlanet(builder);
});
const otherOffsets = (fixture.otherPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
const owner = builder.createString(planet.owner);
OtherPlanet.startOtherPlanet(builder);
OtherPlanet.addNumber(builder, BigInt(planet.number));
OtherPlanet.addX(builder, planet.x);
OtherPlanet.addY(builder, planet.y);
OtherPlanet.addName(builder, name);
OtherPlanet.addOwner(builder, owner);
OtherPlanet.addSize(builder, 9);
return OtherPlanet.endOtherPlanet(builder);
});
const uninhabitedOffsets = (fixture.uninhabitedPlanets ?? []).map(
(planet) => {
const name = builder.createString(planet.name);
UninhabitedPlanet.startUninhabitedPlanet(builder);
UninhabitedPlanet.addNumber(builder, BigInt(planet.number));
UninhabitedPlanet.addX(builder, planet.x);
UninhabitedPlanet.addY(builder, planet.y);
UninhabitedPlanet.addName(builder, name);
UninhabitedPlanet.addSize(builder, 6);
return UninhabitedPlanet.endUninhabitedPlanet(builder);
},
);
const unidentifiedOffsets = (fixture.unidentifiedPlanets ?? []).map(
(planet) => {
UnidentifiedPlanet.startUnidentifiedPlanet(builder);
UnidentifiedPlanet.addNumber(builder, BigInt(planet.number));
UnidentifiedPlanet.addX(builder, planet.x);
UnidentifiedPlanet.addY(builder, planet.y);
return UnidentifiedPlanet.endUnidentifiedPlanet(builder);
},
);
const localVec =
localOffsets.length === 0
? null
: Report.createLocalPlanetVector(builder, localOffsets);
const otherVec =
otherOffsets.length === 0
? null
: Report.createOtherPlanetVector(builder, otherOffsets);
const uninhabitedVec =
uninhabitedOffsets.length === 0
? null
: Report.createUninhabitedPlanetVector(builder, uninhabitedOffsets);
const unidentifiedVec =
unidentifiedOffsets.length === 0
? null
: Report.createUnidentifiedPlanetVector(builder, unidentifiedOffsets);
const totalPlanets =
(fixture.localPlanets ?? []).length +
(fixture.otherPlanets ?? []).length +
(fixture.uninhabitedPlanets ?? []).length +
(fixture.unidentifiedPlanets ?? []).length;
Report.startReport(builder);
Report.addTurn(builder, BigInt(fixture.turn));
Report.addWidth(builder, fixture.mapWidth ?? 4000);
Report.addHeight(builder, fixture.mapHeight ?? 4000);
Report.addPlanetCount(builder, totalPlanets);
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
}
@@ -0,0 +1,239 @@
// Phase 11 end-to-end coverage for the live map integration. Boots
// an authenticated session through `/__debug/store`, mocks the two
// gateway calls the layout makes (`lobby.my.games.list` and
// `user.games.report`), navigates to `/games/<game-id>/map`, and
// asserts the chrome reflects the live data: turn counter shows the
// reported turn, the map view enters its `ready` state with a
// non-zero planet count, and a zero-planet response renders the
// empty world without errors.
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-11-map-session";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
interface MockOpts {
currentTurn: number;
report: Parameters<typeof buildReportPayload>[0];
gameId?: string;
}
interface MockState {
reportRequests: Array<{ gameId: string; turn: number }>;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<MockState> {
const state: MockState = { reportRequests: [] };
const gameId = opts.gameId ?? GAME_ID;
const game: GameFixture = {
gameId,
gameName: "Phase 11 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": {
const decoded = GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
);
const idStruct = decoded.gameId(new UUID());
const hi = idStruct?.hi() ?? 0n;
const lo = idStruct?.lo() ?? 0n;
state.reportRequests.push({
gameId: hiLoToUuid(hi, lo),
turn: decoded.turn(),
});
payload = buildReportPayload(opts.report);
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,
});
},
);
// Hold the SubscribeEvents stream open indefinitely. The
// revocation watcher in `lib/revocation-watcher.ts` treats a clean
// end-of-stream as `session_invalidation` and calls
// `session.signOut("revoked")`, which would bounce the page back
// to `/login`. Playwright aborts pending routes on test teardown,
// the watcher's catch path logs the abort and returns without a
// sign-out — same convention as `tests/e2e/lobby-flow.spec.ts`.
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
return state;
}
function hiLoToUuid(hi: bigint, lo: bigint): string {
const toHex = (v: bigint): string => v.toString(16).padStart(16, "0");
const full = toHex(hi) + toHex(lo);
return [
full.slice(0, 8),
full.slice(8, 12),
full.slice(12, 16),
full.slice(16, 20),
full.slice(20, 32),
].join("-");
}
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,
);
}
test("map view renders the reported turn and planet count from a live report", async ({
page,
}) => {
const mocks = await mockGateway(page, {
currentTurn: 4,
report: {
turn: 4,
mapWidth: 4000,
mapHeight: 4000,
localPlanets: [
{ number: 1, name: "Home", x: 1000, y: 1000 },
{ number: 2, name: "Outpost", x: 1500, y: 1300 },
],
otherPlanets: [
{
number: 3,
name: "Frontier",
x: 2200,
y: 2200,
owner: "Federation",
},
],
uninhabitedPlanets: [{ number: 4, name: "Rock", x: 800, y: 2400 }],
},
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 4");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"4",
);
expect(mocks.reportRequests.length).toBeGreaterThanOrEqual(1);
expect(mocks.reportRequests[0]?.gameId).toBe(GAME_ID);
expect(mocks.reportRequests[0]?.turn).toBe(4);
});
test("zero-planet game renders the empty world without errors", async ({
page,
}) => {
await mockGateway(page, {
currentTurn: 0,
report: {
turn: 0,
mapWidth: 4000,
mapHeight: 4000,
},
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 0");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"0",
);
await expect(page.getByTestId("map-error")).not.toBeVisible();
await expect(page.getByTestId("map-mount-error")).not.toBeVisible();
});
test("missing-membership game surfaces an error instead of a blank canvas", async ({
page,
}) => {
// The gateway returns lobby.my.games.list with a different game id
// so the layout's gameState lookup misses; the store flips to
// `error` and the map view renders the localised error overlay.
await mockGateway(page, {
currentTurn: 0,
gameId: "99999999-aaaa-bbbb-cccc-000000000000",
report: { turn: 0 },
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("map-error")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"error",
);
});
+10 -3
View File
@@ -24,11 +24,18 @@ beforeEach(() => {
});
describe("active-view stubs", () => {
test("map stub renders title and coming-soon copy", () => {
test("map view renders loading overlay when no game-state context is provided", () => {
// The live integration in `lib/active-view/map.svelte` (Phase 11)
// reads its data from a `GameStateStore` provided through context
// by `routes/games/[id]/+layout.svelte`. Without the context the
// store reference is `undefined` and the view stays in the
// `idle` branch, surfacing the localised loading overlay so the
// shell never renders an empty active-view slot.
const ui = render(MapView);
const node = ui.getByTestId("active-view-map");
expect(node).toHaveTextContent("map");
expect(node).toHaveTextContent("coming soon");
expect(node).toHaveAttribute("data-status", "idle");
expect(ui.getByTestId("map-loading")).toBeInTheDocument();
expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument();
});
test("table stub maps a kebab-case entity to the right i18n title", () => {
+264
View File
@@ -0,0 +1,264 @@
// Vitest coverage for the per-game runes store
// (`lib/game-state.svelte.ts`). The test stubs `lobby.my.games.list`
// and `user.games.report` at module level and drives the store
// through its lifecycle: init → ready → error → setTurn → wrap-mode
// persistence.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { Builder } from "flatbuffers";
import { GameStateStore } 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 { UUID } from "../src/proto/galaxy/fbs/common";
import {
LocalPlanet,
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-game-state-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): {
gameId: string;
gameName: string;
gameType: string;
status: string;
ownerUserId: string;
minPlayers: number;
maxPlayers: number;
enrollmentEndsAt: Date;
createdAt: Date;
updatedAt: Date;
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,
};
}
interface PlanetFixture {
number: number;
name: string;
x: number;
y: number;
}
function buildReportPayload(opts: {
turn: number;
width?: number;
height?: number;
planets?: PlanetFixture[];
}): Uint8Array {
const builder = new Builder(256);
const planetOffsets = (opts.planets ?? []).map((planet) => {
const name = builder.createString(planet.name);
LocalPlanet.startLocalPlanet(builder);
LocalPlanet.addNumber(builder, BigInt(planet.number));
LocalPlanet.addX(builder, planet.x);
LocalPlanet.addY(builder, planet.y);
LocalPlanet.addName(builder, name);
LocalPlanet.addSize(builder, 10);
LocalPlanet.addResources(builder, 0.5);
return LocalPlanet.endLocalPlanet(builder);
});
const localPlanetVec =
planetOffsets.length === 0
? null
: Report.createLocalPlanetVector(builder, planetOffsets);
Report.startReport(builder);
Report.addTurn(builder, BigInt(opts.turn));
Report.addWidth(builder, opts.width ?? 4000);
Report.addHeight(builder, opts.height ?? 4000);
Report.addPlanetCount(builder, planetOffsets.length);
if (localPlanetVec !== null) {
Report.addLocalPlanet(builder, localPlanetVec);
}
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
}
function makeFakeClient(
executeCommand: (
messageType: string,
payload: Uint8Array,
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
): GalaxyClient {
return { executeCommand } as unknown as GalaxyClient;
}
describe("GameStateStore", () => {
test("init transitions through loading and ready when both calls succeed", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
const calls: Array<{ messageType: string; payload: Uint8Array }> = [];
const client = makeFakeClient(async (messageType, payload) => {
calls.push({ messageType, payload });
return {
resultCode: "ok",
payloadBytes: buildReportPayload({
turn: 7,
planets: [{ number: 1, name: "Home", x: 100, y: 100 }],
}),
};
});
const store = new GameStateStore();
expect(store.status).toBe("idle");
await store.init({ client, cache, gameId: GAME_ID });
expect(listMyGamesSpy).toHaveBeenCalledTimes(1);
expect(calls.length).toBe(1);
expect(calls[0]?.messageType).toBe("user.games.report");
expect(store.status).toBe("ready");
expect(store.report).not.toBeNull();
expect(store.report?.turn).toBe(7);
expect(store.report?.planets.length).toBe(1);
expect(store.report?.planets[0]?.kind).toBe("local");
store.dispose();
});
test("init surfaces an error when the game is missing from lobby", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
// Replace the helper above's awkward filter with an explicit
// mismatched id so the lookup miss is unambiguous.
listMyGamesSpy.mockResolvedValue([
{ ...makeGameSummary(2), gameId: "different-game-id" },
]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({ turn: 0 }),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.status).toBe("error");
expect(store.error).toMatch(/not in your list/);
expect(store.report).toBeNull();
store.dispose();
});
test("init surfaces error when user.games.report returns a non-ok result", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]);
const client = makeFakeClient(async () => ({
resultCode: "forbidden",
payloadBytes: new TextEncoder().encode(
JSON.stringify({ code: "forbidden", message: "no membership" }),
),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.status).toBe("error");
expect(store.error).toMatch(/no membership/);
store.dispose();
});
test("setTurn loads a different turn snapshot", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
const turns: number[] = [];
const client = makeFakeClient(async () => {
const turn = turns.length === 0 ? 3 : 1;
turns.push(turn);
return {
resultCode: "ok",
payloadBytes: buildReportPayload({ turn }),
};
});
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.report?.turn).toBe(3);
await store.setTurn(1);
expect(store.status).toBe("ready");
expect(store.report?.turn).toBe(1);
store.dispose();
});
test("setWrapMode persists across instances through Cache", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({ turn: 0 }),
}));
const a = new GameStateStore();
await a.init({ client, cache, gameId: GAME_ID });
expect(a.wrapMode).toBe("torus");
await a.setWrapMode("no-wrap");
expect(a.wrapMode).toBe("no-wrap");
a.dispose();
const b = new GameStateStore();
await b.init({ client, cache, gameId: GAME_ID });
expect(b.wrapMode).toBe("no-wrap");
b.dispose();
});
test("failBootstrap moves the store into the error state with the given message", () => {
const store = new GameStateStore();
store.failBootstrap("device session missing");
expect(store.status).toBe("error");
expect(store.error).toBe("device session missing");
});
});
+4 -2
View File
@@ -70,7 +70,7 @@ function makeStub(
return { client: stub, captured };
}
function encodeGameSummary(builder: Builder): number {
function encodeGameSummary(builder: Builder, currentTurn: number = 0): number {
const gameId = builder.createString("g-1");
const gameName = builder.createString("Test Game");
const gameType = builder.createString("private");
@@ -87,6 +87,7 @@ function encodeGameSummary(builder: Builder): number {
GameSummary.addEnrollmentEndsAtMs(builder, 1_780_000_000_000n);
GameSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
GameSummary.addUpdatedAtMs(builder, 1_770_000_000_000n);
GameSummary.addCurrentTurn(builder, currentTurn);
return GameSummary.endGameSummary(builder);
}
@@ -133,7 +134,7 @@ describe("lobby.ts wrappers", () => {
test("listMyGames decodes the response and reports the message type", async () => {
const { client, captured } = makeStub(() => {
const builder = new Builder(256);
const item = encodeGameSummary(builder);
const item = encodeGameSummary(builder, 5);
const items = MyGamesListResponse.createItemsVector(builder, [item]);
MyGamesListResponse.startMyGamesListResponse(builder);
MyGamesListResponse.addItems(builder, items);
@@ -146,6 +147,7 @@ describe("lobby.ts wrappers", () => {
expect(games.length).toBe(1);
expect(games[0]!.gameId).toBe("g-1");
expect(games[0]!.minPlayers).toBe(2);
expect(games[0]!.currentTurn).toBe(5);
});
test("listPublicGames passes pagination and decodes pageSize/total", async () => {
+6 -1
View File
@@ -43,19 +43,21 @@ interface GameSummaryFixture {
enrollmentEndsAtMs: bigint;
createdAtMs: bigint;
updatedAtMs: bigint;
currentTurn: number;
}
const PRIVATE_GAME: GameSummaryFixture = {
gameId: "game-private-7c8f",
gameName: "First Contact",
gameType: "private",
status: "draft",
status: "running",
ownerUserId: "user-9912",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: 1_780_000_000_000n,
createdAtMs: 1_770_000_000_000n,
updatedAtMs: 1_770_000_300_000n,
currentTurn: 7,
};
const PUBLIC_GAME: GameSummaryFixture = {
@@ -69,6 +71,7 @@ const PUBLIC_GAME: GameSummaryFixture = {
enrollmentEndsAtMs: 1_780_500_000_000n,
createdAtMs: 1_770_500_000_000n,
updatedAtMs: 1_770_600_000_000n,
currentTurn: 0,
};
function encodeGameSummary(builder: Builder, value: GameSummaryFixture): number {
@@ -88,6 +91,7 @@ function encodeGameSummary(builder: Builder, value: GameSummaryFixture): number
GameSummary.addEnrollmentEndsAtMs(builder, value.enrollmentEndsAtMs);
GameSummary.addCreatedAtMs(builder, value.createdAtMs);
GameSummary.addUpdatedAtMs(builder, value.updatedAtMs);
GameSummary.addCurrentTurn(builder, value.currentTurn);
return GameSummary.endGameSummary(builder);
}
@@ -104,6 +108,7 @@ function expectGameSummary(actual: GameSummary | null, want: GameSummaryFixture)
expect(got.enrollmentEndsAtMs()).toBe(want.enrollmentEndsAtMs);
expect(got.createdAtMs()).toBe(want.createdAtMs);
expect(got.updatedAtMs()).toBe(want.updatedAtMs);
expect(got.currentTurn()).toBe(want.currentTurn);
}
describe("lobby FlatBuffers TS bindings", () => {
+2
View File
@@ -136,6 +136,7 @@ function makeGame(id: string, name: string, status = "draft") {
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
@@ -151,6 +152,7 @@ function makePublicGame(id: string, name: string) {
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
+114
View File
@@ -0,0 +1,114 @@
// Vitest unit coverage for `map/state-binding.ts`. The function
// translates a Phase 11 `GameReport` into a renderer-ready `World`
// containing one Point primitive per planet across all four kinds
// (local / other / uninhabited / unidentified). The tests assert
// the world dimensions match the report, the planet ids are the
// engine numbers, the kind-specific styles differ, and a zero-planet
// report still produces a well-formed empty World.
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import type { GameReport } from "../src/api/game-state";
import { reportToWorld } from "../src/map/state-binding";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
...overrides,
};
}
describe("reportToWorld", () => {
test("uses report dimensions for the World", () => {
const world = reportToWorld(makeReport({ mapWidth: 3200, mapHeight: 1600 }));
expect(world.width).toBe(3200);
expect(world.height).toBe(1600);
});
test("emits one Point primitive per planet across all four kinds", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "Home", x: 100, y: 100, kind: "local", owner: null, size: 12, resources: 0.5 },
{ number: 2, name: "Alpha", x: 200, y: 100, kind: "other", owner: "Federation", size: 8, resources: 0.3 },
{ number: 3, name: "Rock", x: 100, y: 200, kind: "uninhabited", owner: null, size: 4, resources: 0.1 },
{ number: 4, name: "", x: 200, y: 200, kind: "unidentified", owner: null, size: null, resources: null },
],
}),
);
expect(world.primitives.length).toBe(4);
for (const p of world.primitives) {
expect(p.kind).toBe("point");
}
});
test("propagates planet number as primitive id and coordinates verbatim", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 42, name: "Home", x: 123.5, y: 456.25, kind: "local", owner: null, size: 10, resources: 0.5 },
],
}),
);
const [planet] = world.primitives;
expect(planet?.id).toBe(42);
expect(planet?.kind).toBe("point");
if (planet?.kind === "point") {
expect(planet.x).toBe(123.5);
expect(planet.y).toBe(456.25);
}
});
test("uses distinct styles for each planet kind", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "L", x: 0, y: 0, kind: "local", owner: null, size: 1, resources: 0 },
{ number: 2, name: "O", x: 1, y: 0, kind: "other", owner: "Foe", size: 1, resources: 0 },
{ number: 3, name: "U", x: 2, y: 0, kind: "uninhabited", owner: null, size: 1, resources: 0 },
{ number: 4, name: "?", x: 3, y: 0, kind: "unidentified", owner: null, size: null, resources: null },
],
}),
);
const fills = world.primitives.map((p) => p.style.fillColor);
const unique = new Set(fills);
expect(unique.size).toBe(fills.length);
});
test("zero-planet report yields an empty primitive list and well-formed World", () => {
const world = reportToWorld(makeReport({ planets: [] }));
expect(world.primitives.length).toBe(0);
expect(world.width).toBeGreaterThan(0);
expect(world.height).toBeGreaterThan(0);
});
test("guards against zero / negative dimensions in the report", () => {
const world = reportToWorld(
makeReport({ mapWidth: 0, mapHeight: -1, planets: [] }),
);
// World's constructor rejects non-positive dimensions; the
// binding falls back to 1×1 so a malformed report cannot crash
// the renderer.
expect(world.width).toBeGreaterThan(0);
expect(world.height).toBeGreaterThan(0);
});
test("local planets carry higher priority than unidentified", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "Home", x: 0, y: 0, kind: "local", owner: null, size: 1, resources: 0 },
{ number: 2, name: "?", x: 0, y: 0, kind: "unidentified", owner: null, size: null, resources: null },
],
}),
);
const local = world.primitives.find((p) => p.id === 1);
const unknown = world.primitives.find((p) => p.id === 2);
expect(local?.priority ?? 0).toBeGreaterThan(unknown?.priority ?? 0);
});
});