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