Files
galaxy-game/ui/docs/lobby.md
T
Ilia Denisov ce7a66b3e6 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>
2026-05-08 21:17:17 +02:00

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:

  1. The page calls submitApplication(client, gameId, raceName) from src/api/lobby.ts.
  2. The wrapper builds an ApplicationSubmitRequest FlatBuffers payload, posts it through GalaxyClient.executeCommand, decodes the ApplicationSubmitResponse, and returns an ApplicationSummary plain object.
  3. The lobby page prepends the new application to the my applications list 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 in enrollment_open.
  4. Status starts as pending. When the owner approves, backend creates a membership and the next refresh of lobby.my.games.list surfaces the game in my games. When the owner rejects, the application stays terminal in my applications with status rejected.

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:

  • flatbuffers runtime dependency in ui/frontend/package.json;
  • make -C ui fbs-ts driving flatc --ts to regenerate the bindings from pkg/schema/fbs/*.fbs into ui/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.