feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
+160 -72
View File
@@ -3,30 +3,40 @@
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
shell, the Overview sections, the profile sub-screen, and the
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 and profile share a single chrome implemented in
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project
site's VitePress layout: a left page-list sidebar (Overview /
Profile), 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.
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. Both `lobby-screen.svelte` and
`profile-screen.svelte` populate the same cache through
`account.ensure(client)`, so switching Overview ⇄ Profile never
re-issues `user.account.get` and the strip never flashes the
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 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_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It
@@ -36,27 +46,134 @@ switches the top-level screen to `profile`
lobby-loaded signal. The logout button sits next to it
(`session.signOut("user")`).
The sidebar always renders both pages; clicking the active page is a
no-op. The shell collapses to a horizontal scrolling strip below
640px.
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](#mobile-layout)).
## Overview sections
## Games panels
The Overview page renders one column of sections, top to bottom.
Cards inside each section take the full available width.
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:
| Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) |
| `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`) |
| 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.list` `lobby.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`) | Always visible. |
| `private games` | `lobby.my.games.list` filtered by `owner_user_id === me` `game_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 resolves to
the first visible sub-panel in the canonical order (e.g. with no
games yet it lands on `recruitment`).
### `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](#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
`lobby` and `lobby-create`). The browser Back stack treats it the
`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`](navigation.md)).
@@ -78,7 +195,8 @@ 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")`). When the saved `preferred_language` is one
(`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
@@ -91,42 +209,6 @@ payload to load the matching `user.games.report` for the map view
without an additional gateway call. See
[`game-state.md`](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
@@ -145,19 +227,24 @@ public game (FUNCTIONAL.md §3.3). Fields:
| `start_gap_players` | Advanced toggle | `2` | |
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
On success the create screen returns to the lobby
(`appScreen.go("lobby")`) and the new game shows up in `my games`
once the lobby's onMount has had a chance to refresh the list (the
lobby screen remounts on return, so its onMount re-fires).
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; the page maps it to the matching `lobby.error.<code>` i18n key
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.
`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
@@ -172,5 +259,6 @@ schema. The TS integration ships:
binding drift in CI.
`user.account.get` decodes through the generated `AccountResponse`
table, so the lobby greeting works against a real local stack as well
as the mocked Playwright fixtures.
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.
+50 -11
View File
@@ -17,10 +17,25 @@ for the whole session. The only other routes are the dev/test-only
`/__debug/*` surfaces. What the URL used to encode now lives in two
rune singletons in `src/lib/app-nav.svelte.ts`:
- **`appScreen`** — the top-level screen
(`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the
active `gameId`. It replaces the old `goto`-based redirects and the
`[id]` route param.
- **`appScreen`** — the top-level screen plus the active `gameId`. The
literal values are:
- `login` — anonymous entry point
- `lobby` — historical alias; the dispatcher renders a tiny resolver
that immediately navigates to the first visible games sub-panel
(kept for snapshots persisted before the F8-04b split)
- `lobby-create` — create-game form
- `profile` — profile editor
- `game` — in-game shell (drives `activeView`, see below)
- `games-active-past`, `games-recruitment`, `games-invitations`,
`games-private-games` — the four lobby sub-panels (F8-04b)
- `synthetic-reports` — DEV-only legacy-report loader, gated by
`VITE_GALAXY_DEV_AFFORDANCES === "true"`
It replaces the old `goto`-based redirects and the `[id]` route
param. Sanitize on session-restore allows every literal above, but
the lobby shell's $effect re-routes a restored
`games-private-games` (free tier) or `synthetic-reports` (prod
bundle) to the first visible games sub-panel silently — no toast.
- **`activeView`** — the in-game view (`map` / `table` / `report` /
`battle` / `mail` / `designer-science`) plus the sub-parameters the
old route segments carried (`tableEntity`, `battleId`, `turn`,
@@ -30,16 +45,40 @@ A single-route dispatcher (`src/routes/+page.svelte`) chooses what to
render: it gates on `session.status` (anonymous → login, authenticated
→ the `appScreen.screen`), and for the authenticated tree mounts the
matching screen component from `src/lib/screens/`
(`login-screen.svelte`, `lobby-screen.svelte`,
`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for
`screen === "game"`, the in-game shell
`src/lib/game/game-shell.svelte`. Lobby and profile share a
post-login chrome (sidebar + identity strip) implemented in
`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders
the active view from `activeView` (see below). Navigation is
(`login-screen.svelte`, `lobby-screen.svelte` resolver,
`lobby-create-screen.svelte`, `profile-screen.svelte`,
`games-active-past-screen.svelte`, `games-recruitment-screen.svelte`,
`games-invitations-screen.svelte`,
`games-private-games-screen.svelte`,
`synthetic-reports-screen.svelte`) or, for `screen === "game"`, the
in-game shell `src/lib/game/game-shell.svelte`. Every authenticated
non-game screen wraps its body in
`lib/screens/lobby-shell.svelte`, which renders the two-level sidebar
+ identity strip; see [`lobby.md`](lobby.md). The game shell in turn
renders the active view from `activeView` (see below). Navigation is
`appScreen.go(screen, { gameId })` and `activeView.select(view,
params)` — never `goto`.
### Lobby submenu
The lobby shell renders the `games` parent as an always-expanded
submenu on desktop (>640px) whenever the active screen is one of the
`games-*` literals. The submenu order is canonical
(`active-past``recruitment``invitations``private-games`), and
visibility is computed per-render from the
`account.entitlement.is_paid` flag, `lobbyData.myGames.length`, and
the build-time `VITE_GALAXY_DEV_AFFORDANCES` flag — see the
[Games panels](lobby.md#games-panels) table for the rules.
On mobile (≤640px) the sidebar collapses to a horizontal strip
(F8-04). The `games` entry then renders as a single
`games · {active-sub} ▾` button; tapping it opens a popover
(`role="listbox"`) of every visible sub-panel. Tapping a sub-panel
selects it and closes the popover; tapping outside or pressing
`Escape` closes it 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).
### Active-view dispatch
The client renders **one active view at a time**. The game shell