Files
galaxy-game/ui/docs/lobby.md
T
Ilia Denisov 6fbab5417f
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
Two follow-up nits on the F8-04b sidebar:

1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to
   `games-recruitment` unconditionally on mount. With games already
   in the player's roster the sidebar then highlighted the wrong
   sub-page. The resolver now awaits the lobby fan-out + account
   fetch, then hands off to the same `firstVisibleGamesScreen` helper
   the sidebar uses — so a fresh entry with games lands on
   `active-past`, the canonical-order fallback stays `recruitment`.

2. `games-invitations` was unconditionally visible in the sidebar.
   Now it follows the `active-past` rule: hidden until the
   pending-invites list reports >=1. The lobby shell's auto-kick
   effect treats it symmetrically — accepting / declining the last
   invite moves the player to the next visible sub-page once the
   fan-out has resolved.

Acceptance order in games-invitations-screen.acceptInvite was also
swapped to setMyGames-before-removeInvitation: both mutations land
in the same microtask, so the new auto-kick sees the freshly added
game in `myGames` when invitations drop to zero and routes the
player to `active-past` instead of bouncing through `recruitment`.

The visibility predicates and canonical order live in the new
`src/lib/lobby-nav.ts` pure helper, shared between the sidebar and
the resolver so they cannot disagree. Unit tests cover every
combination of (hasMyGames, hasInvitations, isPaidOrDev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:17:57 +02:00

16 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). This doc captures the shared shell, the four games sub-panels, the profile sub-screen, the DEV-only synthetic-reports loader, the paid-tier gate, and the defaults baked into the create-game form.

Shell

Lobby pages, profile, and the synthetic-reports loader share a single chrome implemented in lib/screens/lobby-shell.svelte. The chrome mirrors the project site's VitePress layout: a two-level left sidebar, a top identity strip on the right, and the page content in the right-hand column. The shell uses var(--font-mono) so the post-login pages adopt the "nerdy" type stack that the public site already uses.

Top-level sidebar items:

Item Visibility
games always; renders a submenu (see below)
profile always
synthetic test reports only when VITE_GALAXY_DEV_AFFORDANCES === "true" (DEV / dev-deploy bundles); stripped from prod by Vite

The identity strip reads the caller's account from lib/account-store.svelte.ts — a session-wide cache that fetches user.account.get once on first access and is written through after every Profile save. Every sub-screen populates the same cache through account.ensure(client), so navigating between panels never re-issues user.account.get and the strip never flashes the lobby.account_loading placeholder mid-navigation. The cache is cleared by session.signOut("user") / signOut("revoked") so a different user signing in on the same browser does not briefly see the previous identity. The matching lobby-data cache (lib/lobby-data.svelte.ts) is cleared in the same path so the public-games / invitations / applications snapshots do not leak across sessions.

The strip falls back to display_name → immutable user_namelobby.account_loading while the first ensure(...) resolves. It renders as a data-testid="lobby-account-name" button; clicking it switches the top-level screen to profile (appScreen.go("profile")). The e2e suites use that testid as their lobby-loaded signal. The logout button sits next to it (session.signOut("user")).

Clicking the active item is a no-op (mirrors the F8-02 idiom from issue #45). The sidebar collapses to a horizontal scrolling strip below 640px; the games item then renders as a dropdown labeled games · {active-sub} ▾ (see Mobile layout).

Games panels

The games parent expands into a submenu in the canonical order below. Visibility predicates are evaluated per-render so the submenu contents follow the lobby-data store and the account tier:

Sub-panel Source Visibility
active-past lobby.my.games.list Visible only when the list is non-empty. Empty → the sub-panel is hidden entirely (no empty card surfaces).
recruitment lobby.public.games.listlobby.my.applications.list Always visible. Public games where the caller is not the owner; each card surfaces the caller's application status as a chip (pending / approved / rejected / unknown) when there is one. Stale pending/approved applications on closed games render as standalone "applied" cards; stale rejected/unknown ones are hidden.
invitations lobby.my.invites.list (status=pending) Visible only when the pending-invites list is non-empty. Empty (or while the fan-out is still in flight) → the sub-panel is hidden, mirroring the active-past rule.
private games lobby.my.games.list filtered by owner_user_id === megame_type === "private" Paid tier only (account.entitlement.is_paid === true). VITE_GALAXY_DEV_AFFORDANCES overrides for DEV bundles.

Clicking the games parent without choosing a sub-panel — or arriving on the bare lobby alias (a pre-split persisted snapshot, or a programmatic appScreen.go("lobby")) — resolves to the first visible sub-panel in the canonical order. The resolver awaits the lobby.*.list fan-out and account fetch before deciding, so a fresh entry with games already in the player's roster lands on active-past, an invitee-only account lands on invitations, and the deterministic fallback is recruitment (always visible). The predicates live in src/lib/lobby-nav.ts and are shared between this resolver and the sidebar's submenu, so the two surfaces never disagree.

recruitment — inline application form

Submit application on a recruitment card toggles an inline race-name form on the same card. The form is rendered when the caller either has no application for that game or the latest application status is rejected (so the caller can try again). On pending / approved the form is hidden — a single-line "your application is awaiting approval" / "your application was accepted" note replaces it. 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.
  3. The lobby-data store prepends the new application and the inline form collapses. The public-games snapshot is unchanged.
  4. Status starts as pending. When the owner approves, backend creates a membership and the next refresh surfaces the game in active-past (with the membership) — the recruitment card stops showing the form because the application is approved.

private games — create-game entry point

The right-hand corner of the private games panel hosts the create new game button (data-testid="lobby-create-button"). It opens the lobby-create top-level screen. When the panel is hidden (free tier, no DEV override) the button is not in the DOM and the underlying lobby.game.create is rejected with 403 forbidden by backend regardless of UI state — see Tier gate.

Invite lifecycle (invitations)

A pending invite arrives in 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 lobby-data store refreshes lobby.my.games.list, and the freshly-joined game appears in active-past.
  • Decline — the invite card disappears. No membership is created.

Mobile layout

The sidebar collapses to a horizontal scrolling strip below 640px (the breakpoint set by lobby-shell.svelte). On mobile the games item is replaced by a single games · {active-sub} ▾ button. Tapping the button opens a popover (role="listbox") listing every visible games sub-panel; tapping a sub-panel selects it and closes the popover. Tapping outside or pressing Escape closes the popover without changing the active page. Re-tapping the active sub-panel inside the popover is a no-op — the same idiom as the F8-02 turn-navigator fix in issue #45.

Hidden sub-panels (e.g. active-past when the player has no games, private games on free tier without the DEV override) do not appear in the popover, mirroring the desktop submenu.

Tier gate

lobby.game.create is gated by the paid tier:

  • UI: the private games sub-panel and the create new game button are hidden from the sidebar / panel chrome when account.entitlement.is_paid !== true. VITE_GALAXY_DEV_AFFORDANCES === "true" flips both back on so the owner can exercise paid-only flows from a free-tier test account.
  • Backend: POST /api/v1/user/lobby/games checks EntitlementProvider.IsPaid(ctx, userID) before invoking lobby.Service.CreateGame. Free callers receive 403 {"error":{"code":"forbidden","message":"creating private games requires a paid subscription"}}. The lobby-create screen catches the forbidden LobbyError and renders an inline message (lobby.create.error.forbidden); no redirect, no toast.

Admin-driven public-game creation (POST /api/v1/admin/games) bypasses the gate.

Known limitation: the account cache is not invalidated when an admin upgrades the user mid-session — the user has to log out and back in to see private games appear. The matching follow-up is out of scope for this change; the cache pattern lives in account-store.svelte.ts::ensure.

Synthetic reports (DEV)

lib/screens/synthetic-reports-screen.svelte lifts the old Overview dev-loader into its own top-level screen, surfaced only when VITE_GALAXY_DEV_AFFORDANCES === "true". Reports are JSON files produced offline by the Go CLI in tools/local-dev/legacy-report/; loading one opens the map view against a synthetic snapshot. See ui/docs/testing.md#synthetic-reports for the workflow.

The flag is statically evaluated by Vite, so prod bundles strip the whole screen out of the tree and the matching synthetic-reports AppScreen literal becomes unreachable; the shell's $effect re-routes a stale snapshot pointing at it to the first visible games sub-panel without surfacing an error.

Profile sub-screen

lib/screens/profile-screen.svelte is a top-level AppScreen (peer of games-* / lobby-create). The browser Back stack treats it the same as the create screen — pushing a fresh history entry on entry, falling back to lobby on Back/Forward (see navigation.md).

On mount it reads the caller's account through account.ensure(...) (see Shell) — the first visit issues user.account.get, subsequent visits resolve from the session-wide cache without a gateway round-trip. The form renders an identity read-out (immutable user_name, email) plus three editable fields:

Field Endpoint Notes
display_name user.profile.update Trimmed; empty value clears the stored name (backend PATCH semantics).
preferred_language user.settings.update <select> over SUPPORTED_LOCALES; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it.
time_zone user.settings.update <select> of every IANA zone the browser knows (Intl.supportedValuesOf("timeZone")), grouped by leading slash segment (Africa / America / …; singletons like UTC collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to Intl.DateTimeFormat().resolvedOptions().timeZone. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack supportedValuesOf fall back to a free-text input; the backend validates with time.LoadLocation in every shape.

Save fires user.profile.update and/or user.settings.update conditionally on which fields actually changed, then stays on the profile and surfaces a transient profile-saved-notice line (data-testid="profile-saved-notice"). Editing any field clears the notice. Only the explicit cancel button navigates back to the lobby (appScreen.go("lobby"), which the shell resolves to the first visible games sub-panel). When the saved preferred_language is one the UI also ships translations for, the active i18n locale switches in-place so the rest of the session matches the new preference. The write-through is also pushed into the shared account store so the shell identity strip picks up the new display_name without a second user.account.get.

GameSummary carries a current_turn field 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.

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 create screen navigates to the games-private-games sub-panel so the freshly-created game shows up immediately (the lobby-data store refreshes on the next sub-panel mount). On failure the gateway error is rendered inline below the form via lobby-create-error; forbidden from the backend tier gate is translated to lobby.create.error.forbidden (paid-tier message) instead of the generic operation-forbidden text.

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; each 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. The lobby-create screen overrides forbidden to the dedicated paid-tier message (lobby.create.error.forbidden).

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. The TS integration 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.

user.account.get decodes through the generated AccountResponse table — the lobby greeting works against a real local stack as well as the mocked Playwright fixtures, and the entitlement projection (account.entitlement.is_paid) lights up the paid-tier sub-panels.