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:
@@ -0,0 +1,178 @@
|
||||
// Typed wrapper around `GalaxyClient.executeCommand("user.games.report",
|
||||
// ...)`. The signed-gRPC wire shape is the FlatBuffers
|
||||
// `report.GameReportRequest` for the request and `report.Report` for
|
||||
// the response (see `pkg/schema/fbs/report.fbs`). Phase 11 only
|
||||
// surfaces the planet subset of the response — full ship / fleet /
|
||||
// science decoding lands in Phases 17-22.
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
|
||||
import type { GalaxyClient } from "./galaxy-client";
|
||||
import { UUID } from "../proto/galaxy/fbs/common";
|
||||
import {
|
||||
GameReportRequest,
|
||||
Report,
|
||||
} from "../proto/galaxy/fbs/report";
|
||||
|
||||
const MESSAGE_TYPE = "user.games.report";
|
||||
|
||||
export class GameStateError extends Error {
|
||||
readonly resultCode: string;
|
||||
readonly code: string;
|
||||
|
||||
constructor(resultCode: string, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "GameStateError";
|
||||
this.resultCode = resultCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReportPlanet {
|
||||
number: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
kind: "local" | "other" | "uninhabited" | "unidentified";
|
||||
owner: string | null;
|
||||
size: number | null;
|
||||
resources: number | null;
|
||||
}
|
||||
|
||||
export interface GameReport {
|
||||
turn: number;
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
planetCount: number;
|
||||
planets: ReportPlanet[];
|
||||
}
|
||||
|
||||
export async function fetchGameReport(
|
||||
client: GalaxyClient,
|
||||
gameId: string,
|
||||
turn: number,
|
||||
): Promise<GameReport> {
|
||||
const builder = new Builder(64);
|
||||
const [hi, lo] = uuidToHiLo(gameId);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
GameReportRequest.startGameReportRequest(builder);
|
||||
GameReportRequest.addGameId(builder, gameIdOffset);
|
||||
GameReportRequest.addTurn(builder, turn);
|
||||
builder.finish(GameReportRequest.endGameReportRequest(builder));
|
||||
|
||||
const result = await client.executeCommand(MESSAGE_TYPE, builder.asUint8Array());
|
||||
if (result.resultCode !== "ok") {
|
||||
const { code, message } = decodeErrorMessage(result.payloadBytes);
|
||||
throw new GameStateError(result.resultCode, code, message);
|
||||
}
|
||||
const buffer = new ByteBuffer(result.payloadBytes);
|
||||
const report = Report.getRootAsReport(buffer);
|
||||
return decodeReport(report);
|
||||
}
|
||||
|
||||
function decodeReport(report: Report): GameReport {
|
||||
const planets: ReportPlanet[] = [];
|
||||
|
||||
for (let i = 0; i < report.localPlanetLength(); i++) {
|
||||
const p = report.localPlanet(i);
|
||||
if (p === null) continue;
|
||||
planets.push({
|
||||
number: Number(p.number()),
|
||||
name: p.name() ?? "",
|
||||
x: p.x(),
|
||||
y: p.y(),
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.otherPlanetLength(); i++) {
|
||||
const p = report.otherPlanet(i);
|
||||
if (p === null) continue;
|
||||
planets.push({
|
||||
number: Number(p.number()),
|
||||
name: p.name() ?? "",
|
||||
x: p.x(),
|
||||
y: p.y(),
|
||||
kind: "other",
|
||||
owner: p.owner() ?? null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.uninhabitedPlanetLength(); i++) {
|
||||
const p = report.uninhabitedPlanet(i);
|
||||
if (p === null) continue;
|
||||
planets.push({
|
||||
number: Number(p.number()),
|
||||
name: p.name() ?? "",
|
||||
x: p.x(),
|
||||
y: p.y(),
|
||||
kind: "uninhabited",
|
||||
owner: null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.unidentifiedPlanetLength(); i++) {
|
||||
const p = report.unidentifiedPlanet(i);
|
||||
if (p === null) continue;
|
||||
planets.push({
|
||||
number: Number(p.number()),
|
||||
name: "",
|
||||
x: p.x(),
|
||||
y: p.y(),
|
||||
kind: "unidentified",
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
turn: Number(report.turn()),
|
||||
mapWidth: report.width(),
|
||||
mapHeight: report.height(),
|
||||
planetCount: report.planetCount(),
|
||||
planets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* uuidToHiLo splits the canonical 36-character UUID string
|
||||
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
||||
* uint64 halves used by `common.UUID`. Mirrors `pkg/transcoder/uuid.go`.
|
||||
*/
|
||||
export function uuidToHiLo(value: string): [bigint, bigint] {
|
||||
const hex = value.replace(/-/g, "").toLowerCase();
|
||||
if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) {
|
||||
throw new GameStateError(
|
||||
"invalid_request",
|
||||
"invalid_request",
|
||||
`invalid uuid: ${value}`,
|
||||
);
|
||||
}
|
||||
const hi = BigInt(`0x${hex.slice(0, 16)}`);
|
||||
const lo = BigInt(`0x${hex.slice(16, 32)}`);
|
||||
return [hi, lo];
|
||||
}
|
||||
|
||||
function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } {
|
||||
if (payload.length === 0) {
|
||||
return { code: "internal_error", message: "empty error payload" };
|
||||
}
|
||||
try {
|
||||
const text = new TextDecoder().decode(payload);
|
||||
const parsed = JSON.parse(text) as { code?: string; message?: string };
|
||||
return {
|
||||
code: typeof parsed.code === "string" ? parsed.code : "internal_error",
|
||||
message: typeof parsed.message === "string" ? parsed.message : text,
|
||||
};
|
||||
} catch {
|
||||
return { code: "internal_error", message: "non-json error payload" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user