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
+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>