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
+173 -15
View File
@@ -1,29 +1,187 @@
<!--
Phase 10 stub for the map active view. Phase 11 swaps this for the
live renderer integration described in `ui/PLAN.md` Phase 11. The
stub keeps the same `data-testid` so Phase 11's spec replaces the
copy assertion without touching navigation.
Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps
the existing renderer instance alive). Empty-planet reports render
the empty world without errors — the regression test in
`tests/e2e/game-shell-map.spec.ts` covers this.
Phase 9 owns the renderer's hit-test and pan/zoom semantics; Phase 13
will plug map clicks into the inspector. Phase 29 wires the wrap-mode
toggle on top of the per-game `wrapMode` preference the store
already manages.
-->
<script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
createRenderer,
minScaleNoWrap,
type RendererHandle,
} from "../../map/index";
import { reportToWorld } from "../../map/state-binding";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let mountError: string | null = $state(null);
let handle: RendererHandle | null = null;
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let mounted = false;
$effect(() => {
const report = store?.report;
const status = store?.status ?? "idle";
// Track the wrap mode so the renderer remounts when Phase 29's
// toggle UI flips it; the read here also subscribes the effect.
const mode = store?.wrapMode ?? "torus";
const gameId = store?.gameId ?? "";
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return;
// Skip a re-mount when the same turn is reloaded for the same
// game and the wrap mode did not change. The store's `refresh`
// path lands here on tab focus; an unchanged snapshot must not
// flicker the canvas.
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
handle !== null &&
handle.getMode() === mode;
if (sameSnapshot) return;
untrack(() => {
void mountRenderer(report, mode);
});
});
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
if (handle !== null) {
handle.dispose();
handle = null;
}
try {
const world = reportToWorld(report);
handle = await createRenderer({
canvas: canvasEl,
world,
mode,
preference: ["webgpu", "webgl"],
});
handle.viewport.moveCenter(world.width / 2, world.height / 2);
const minScale = minScaleNoWrap(
{
widthPx: containerEl.clientWidth,
heightPx: containerEl.clientHeight,
},
world,
);
handle.viewport.setZoom(minScale * 1.05, true);
if (mode === "no-wrap") handle.setMode("no-wrap");
mountedTurn = report.turn;
mountedGameId = store?.gameId ?? "";
mountError = null;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
}
onMount(() => {
mounted = true;
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
});
onDestroy(() => {
mounted = false;
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
});
</script>
<section class="active-view" data-testid="active-view-map">
<h2>{i18n.t("game.view.map")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
<section class="active-view" data-testid="active-view-map" data-status={store?.status ?? "idle"}>
{#if store?.status === "error"}
<p class="overlay error" role="alert" data-testid="map-error">
{store.error ?? "request failed"}
</p>
{:else if mountError !== null}
<p class="overlay error" role="alert" data-testid="map-mount-error">
{mountError}
</p>
{:else if store?.status !== "ready"}
<p class="overlay" data-testid="map-loading">{i18n.t("common.loading")}</p>
{/if}
<div
class="canvas-wrap"
data-testid="map-canvas-wrap"
data-planet-count={store?.report?.planets.length ?? 0}
bind:this={containerEl}
>
<canvas bind:this={canvasEl}></canvas>
</div>
</section>
<style>
.active-view {
padding: 1.5rem;
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.canvas-wrap {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
background: #0a0e1a;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 0.9rem;
background: rgba(20, 24, 42, 0.85);
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
z-index: 10;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
font-size: 0.9rem;
margin: 0;
color: #555;
}
.overlay.error {
background: #4a1820;
border-color: #6d2530;
color: #ffb4b4;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
// Per-game runtime state owned by the in-game shell layout
// (`routes/games/[id]/+layout.svelte`). The store discovers the
// game's current turn through `lobby.my.games.list`, fetches the
// matching `user.games.report`, and exposes a TS-friendly `GameReport`
// snapshot to every consumer (header turn counter, map view,
// inspector tabs in later phases).
//
// Phase 11 covers planets only; later phases extend the report
// surface as their slice of state lands. Every consumer reads from
// the same store instance — instantiation per game guarantees the
// layout remount on `gameId` change reseeds the snapshot, while
// navigation between active views inside the same game keeps the
// instance alive (state-preservation rule, see ui/docs/navigation.md).
import {
GameStateError,
fetchGameReport,
type GameReport,
} from "../api/game-state";
import { listMyGames, type GameSummary } from "../api/lobby";
import type { GalaxyClient } from "../api/galaxy-client";
import type { Cache } from "../platform/store/index";
import type { WrapMode } from "../map/world";
const PREF_NAMESPACE = "game-prefs";
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants.
* Header / map / inspector children resolve the store via
* `getContext(GAME_STATE_CONTEXT_KEY)`.
*/
export const GAME_STATE_CONTEXT_KEY = Symbol("game-state");
type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore {
gameId: string = $state("");
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
error: string | null = $state(null);
private client: GalaxyClient | null = null;
private cache: Cache | null = null;
private currentTurn = 0;
private destroyed = false;
private visibilityListener: (() => void) | null = null;
/**
* init kicks off the per-game lifecycle. The call is idempotent on
* the same `gameId`; calling with a different game forwards through
* `setGame` so the layout can hand off across navigations.
*/
async init(opts: {
client: GalaxyClient;
cache: Cache;
gameId: string;
}): Promise<void> {
this.client = opts.client;
this.cache = opts.cache;
await this.setGame(opts.gameId);
this.installVisibilityListener();
}
/**
* setGame switches the store to the supplied game id, fetches the
* matching lobby record to discover `current_turn`, then loads the
* report. Failure paths surface through `status === "error"` and
* the matching `error` string (already localised by the caller).
*/
async setGame(gameId: string): Promise<void> {
if (this.client === null || this.cache === null) {
throw new Error("game-state: setGame called before init");
}
this.gameId = gameId;
this.status = "loading";
this.error = null;
this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId);
try {
const summary = await this.findGame(gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${gameId} is not in your list`;
return;
}
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* setTurn loads a different turn snapshot — used by Phase 26 history
* mode. The current turn stays at whatever `setGame` discovered;
* calling without an argument refetches the same turn.
*/
async setTurn(turn: number): Promise<void> {
if (this.client === null) return;
this.status = "loading";
this.error = null;
try {
await this.loadTurn(turn);
} catch (err) {
this.status = "error";
this.error = describe(err);
}
}
/**
* refresh re-fetches the report at the current turn. Called on
* window `visibilitychange` so the map and the turn counter stay
* fresh after the user returns to the tab.
*/
refresh(): Promise<void> {
return this.setTurn(this.currentTurn);
}
/**
* setWrapMode persists the per-game preference into Cache so the
* next visit to the game restores it. Phase 29 wires the toggle UI;
* Phase 11 only reads through `wrapMode` and writes via this method.
*/
async setWrapMode(mode: WrapMode): Promise<void> {
this.wrapMode = mode;
if (this.cache !== null) {
await this.cache.put(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(this.gameId), mode);
}
}
/**
* failBootstrap is used by the layout to surface errors that
* happen *before* `init` could be reached (missing keypair, missing
* gateway public key, core/store load failure). It does not need
* `init` to have run first.
*/
failBootstrap(message: string): void {
this.status = "error";
this.error = message;
}
dispose(): void {
this.destroyed = true;
if (this.visibilityListener !== null && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this.visibilityListener);
}
this.visibilityListener = null;
this.client = null;
this.cache = null;
}
private async findGame(gameId: string): Promise<GameSummary | null> {
if (this.client === null) return null;
const games = await listMyGames(this.client);
return games.find((g) => g.gameId === gameId) ?? null;
}
private async loadTurn(turn: number): Promise<void> {
if (this.client === null) return;
const report = await fetchGameReport(this.client, this.gameId, turn);
if (this.destroyed) return;
this.report = report;
this.currentTurn = turn;
this.status = "ready";
}
private installVisibilityListener(): void {
if (typeof document === "undefined") return;
const listener = (): void => {
if (document.visibilityState === "visible" && this.status === "ready") {
void this.refresh();
}
};
this.visibilityListener = listener;
document.addEventListener("visibilitychange", listener);
}
}
async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
const stored = await cache.get<string>(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(gameId));
if (stored === "no-wrap") return "no-wrap";
return "torus";
}
function describe(err: unknown): string {
if (err instanceof GameStateError) {
return err.message;
}
if (err instanceof Error) {
return err.message;
}
return "request failed";
}
+22 -6
View File
@@ -1,15 +1,31 @@
<!--
Phase 10 placeholder turn counter. The displayed value is the static
`?` glyph from `game.shell.turn_unknown`; Phase 11 swaps the source
to the live game state. The wrapping span is kept as the public
shape so Phase 11 only needs to replace the inner text.
Phase 11 turn counter: reads the live turn number from the per-game
`GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
from `game.shell.turn_unknown` when the store has not yet produced a
report (boot, network error, no membership) so the header chrome
keeps its width across loading transitions.
Phase 26 will turn this into a clickable trigger that opens the
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
that may flash this counter when a new turn is ready.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { GAME_STATE_CONTEXT_KEY, type GameStateStore } from "$lib/game-state.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const display = $derived.by(() => {
const report = store?.report ?? null;
if (report === null) return i18n.t("game.shell.turn_unknown");
return String(report.turn);
});
</script>
<span class="turn" data-testid="turn-counter">
{i18n.t("game.shell.turn_label")}&nbsp;{i18n.t("game.shell.turn_unknown")}
<span class="turn" data-testid="turn-counter" data-turn={display}>
{i18n.t("game.shell.turn_label")}&nbsp;{display}
</span>
<style>