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>
6.3 KiB
Lobby UI
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
docs/auth-flow.md). Phase 8 introduced the live
lobby with five sections, the create-game form, and the TS-side
FlatBuffers integration the rest of the client builds on. This doc
captures the sections, the application / invite lifecycle the user
sees, and the defaults baked into the create-game form.
Sections
The lobby renders one column of sections, top to bottom, with the
common content max-width capped at 32rem (same convention as the
login page). Cards inside each section take the full available
width.
| Section | Empty state | Source | Action |
|---|---|---|---|
create new game |
(always visible) | — | Navigates to /lobby/create |
my games |
no games yet |
lobby.my.games.list |
Click → /games/:id/map |
pending invitations |
no invitations |
lobby.my.invites.list |
Accept (lobby.invite.redeem) / Decline (lobby.invite.decline) |
my applications |
no applications |
lobby.my.applications.list |
Status badge (pending / approved / rejected) |
public games |
no public games |
lobby.public.games.list |
Submit application via inline race-name form (lobby.application.submit) |
The header preserves the device-session-id <code> block from the
Phase 7 placeholder (kept as a debug affordance) plus a greeting if
the gateway returns a display_name for the caller.
GameSummary carries an extra current_turn field (Phase 11
extension) that the lobby UI does not display directly — the in-game
shell reads it from the same payload to load the matching
user.games.report for the map view without an additional gateway
call. See game-state.md for the consumer's view.
Application lifecycle
Submit application on a public-game card toggles an inline race-name
form on the same card (no overlay/modal infrastructure yet — the
in-game shell that introduces overlays lands later). On submit:
- The page calls
submitApplication(client, gameId, raceName)fromsrc/api/lobby.ts. - The wrapper builds an
ApplicationSubmitRequestFlatBuffers payload, posts it throughGalaxyClient.executeCommand, decodes theApplicationSubmitResponse, and returns anApplicationSummaryplain object. - The lobby page prepends the new application to the
my applicationslist and collapses the inline form. The page does not refresh the public-games list — backend semantics are that the public game still exists and is still inenrollment_open. - Status starts as
pending. When the owner approves, backend creates a membership and the next refresh oflobby.my.games.listsurfaces the game inmy games. When the owner rejects, the application stays terminal inmy applicationswith statusrejected.
Invite lifecycle
A pending invite arrives in pending invitations either when the
inviter targets the user by id (invited_user_id is set) or when the
user redeems a code-based invite from somewhere outside the lobby.
The user can accept (lobby.invite.redeem) or decline
(lobby.invite.decline):
- Accept — the invite card disappears, the page refreshes
my games, and the freshly-joined game appears there. - Decline — the invite card disappears. No membership is created.
Create-game form
The form posts lobby.game.create through the gateway with
visibility="private" hard-coded; the user surface never produces a
public game (FUNCTIONAL.md §3.3). Fields:
| Field | Visibility | Default | Notes |
|---|---|---|---|
game_name |
always | "" |
Non-empty client-side check |
description |
always | "" |
|
turn_schedule |
always | 0 0 * * * |
Plain text input, hint says "five-field cron" |
enrollment_ends_at |
always | "" |
<input type="datetime-local">, RFC 3339 on submit |
min_players |
Advanced toggle | 2 |
<details> block |
max_players |
Advanced toggle | 8 |
|
start_gap_hours |
Advanced toggle | 24 |
|
start_gap_players |
Advanced toggle | 2 |
|
target_engine_version |
Advanced toggle | v1 |
Falls back to v1 if blank |
On success the page navigates back to /lobby and the new game shows
up in my games once the lobby's onMount has had a chance to refresh
the list.
Errors
Lobby errors raised by the gateway carry a canonical code
(invalid_request, subject_not_found, forbidden, conflict,
internal_error). The LobbyError thrown by lobby.ts exposes the
code; the page maps it to the matching lobby.error.<code> i18n key
and falls back to the gateway-supplied message via
lobby.error.unknown for any unknown code.
Why FlatBuffers on the TS side
The gateway encodes lobby payloads through pkg/transcoder/lobby.go
into FlatBuffers bytes; the browser must decode them with the same
schema. Phase 8 ships:
flatbuffersruntime dependency inui/frontend/package.json;make -C ui fbs-tsdrivingflatc --tsto regenerate the bindings frompkg/schema/fbs/*.fbsintoui/frontend/src/proto/galaxy/fbs/;- a Vitest round-trip suite (
tests/lobby-fbs.test.ts) that catches binding drift in CI.
Phase 7's user.account.get decode previously used
JSON.parse(TextDecoder); that path was rewritten in Phase 8 to use
the same generated AccountResponse table, so the lobby greeting now
works against a real local stack as well as the mocked Playwright
fixtures.