feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game #62

Merged
developer merged 4 commits from feature/f8-04b-lobby-restructure into development 2026-05-27 07:18:33 +00:00
44 changed files with 2486 additions and 1118 deletions
Showing only changes of commit 009ea560f9 - Show all commits
+11
View File
@@ -485,6 +485,17 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
return snap.MaxRegisteredRaceNames, nil return snap.MaxRegisteredRaceNames, nil
} }
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if a == nil || a.svc == nil {
return false, nil
}
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
if err != nil {
return false, err
}
return snap.IsPaid, nil
}
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by // runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
// delegating to `*runtime.Service`. The svc pointer is patched after // delegating to `*runtime.Service`. The svc pointer is patched after
// the services are constructed — runtime depends on lobby // the services are constructed — runtime depends on lobby
+12 -5
View File
@@ -9,14 +9,21 @@ import (
// EntitlementProvider is the read-only view the lobby needs over the // EntitlementProvider is the read-only view the lobby needs over the
// user-domain entitlement snapshot. The canonical implementation is // user-domain entitlement snapshot. The canonical implementation is
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute // `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
// a fake. // substitute a fake.
// //
// `MaxRegisteredRaceNames` is the only field consumed by when // `GetMaxRegisteredRaceNames` is consumed at race-name registration time
// the caller attempts to register a `pending_registration` row the lobby // — when the caller attempts to register a `pending_registration` row the
// counts already-`registered` rows for that user against this limit. // lobby counts already-`registered` rows for that user against this limit.
//
// `IsPaid` is consumed by the user-facing private-game creation gate at
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
// callers are rejected with `403 forbidden` before the lobby Service is
// invoked. Admin-driven public-game creation
// (`POST /api/v1/admin/games`) bypasses the gate.
type EntitlementProvider interface { type EntitlementProvider interface {
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error) GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
} }
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime // RuntimeGateway is the outbound surface the lobby uses to ask the runtime
+13
View File
@@ -20,6 +20,7 @@
package lobby package lobby
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -28,6 +29,7 @@ import (
"galaxy/backend/internal/config" "galaxy/backend/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -207,6 +209,17 @@ func (s *Service) Config() config.LobbyConfig {
return s.deps.Config return s.deps.Config
} }
// IsPaid reports whether userID currently sits on a paid tier. Thin
// pass-through over EntitlementProvider used by the HTTP handler that
// fronts user-driven private-game creation; admin-driven public-game
// creation does not consult this gate.
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if s == nil || s.deps.Entitlement == nil {
return false, fmt.Errorf("lobby: entitlement provider not configured")
}
return s.deps.Entitlement.IsPaid(ctx, userID)
}
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used // generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to // for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller. // read entropy is propagated to the caller.
+4
View File
@@ -103,6 +103,10 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
return s.max, nil return s.max, nil
} }
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
return true, nil
}
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service { func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
t.Helper() t.Helper()
store := lobby.NewStore(db) store := lobby.NewStore(db)
@@ -86,6 +86,15 @@ func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
return return
} }
ctx := c.Request.Context() ctx := c.Request.Context()
paid, err := h.svc.IsPaid(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
return
}
if !paid {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "creating private games requires a paid subscription")
return
}
owner := userID owner := userID
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{ game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &owner, OwnerUserID: &owner,
+8 -1
View File
@@ -265,7 +265,12 @@ paths:
summary: Create a new private lobby game owned by the caller summary: Create a new private lobby game owned by the caller
description: | description: |
Always emits a `private` game owned by `X-User-ID`. Public games Always emits a `private` game owned by `X-User-ID`. Public games
are created via `POST /api/v1/admin/games`. are created via `POST /api/v1/admin/games`. The endpoint is
gated by the caller's paid tier: free-tier accounts receive
`403 forbidden` (code `forbidden`) and no `draft` row is
created. The tier is read through
`EntitlementProvider.IsPaid(userID)` from the user-domain
service.
security: security:
- UserHeader: [] - UserHeader: []
parameters: parameters:
@@ -285,6 +290,8 @@ paths:
$ref: "#/components/schemas/LobbyGameDetail" $ref: "#/components/schemas/LobbyGameDetail"
"400": "400":
$ref: "#/components/responses/InvalidRequestError" $ref: "#/components/responses/InvalidRequestError"
"403":
$ref: "#/components/responses/ForbiddenError"
"501": "501":
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
+3 -1
View File
@@ -142,7 +142,9 @@ because they cross domain boundaries:
- **Public lobby games are admin-created** through - **Public lobby games are admin-created** through
`POST /api/v1/admin/games`. The user-facing `POST /api/v1/admin/games`. The user-facing
`POST /api/v1/user/lobby/games` always emits `private` games owned by `POST /api/v1/user/lobby/games` always emits `private` games owned by
`X-User-ID`. Public games carry `owner_user_id IS NULL`; the partial `X-User-ID`, and is gated by `EntitlementProvider.IsPaid` — free-tier
callers receive `403 forbidden` before the lobby service is invoked.
Public games carry `owner_user_id IS NULL`; the partial
index on `(owner_user_id) WHERE visibility = 'private'` keeps the index on `(owner_user_id) WHERE visibility = 'private'` keeps the
private-owner lookup efficient. private-owner lookup efficient.
- **Authenticated lobby commands** flow through the gateway envelope - **Authenticated lobby commands** flow through the gateway envelope
+12
View File
@@ -363,6 +363,18 @@ records the new game with `owner_user_id` set to the caller and
visibility `private`, in state `draft`, with the request body's visibility `private`, in state `draft`, with the request body's
configuration as initial values. configuration as initial values.
The user surface is gated by the caller's paid tier. Backend reads
`EntitlementProvider.IsPaid(userID)` before invoking the lobby
service; free-tier callers are rejected with HTTP
`403 forbidden` (canonical error code `forbidden`) and no `draft`
row is created. The matching UI affordances — the `private games`
sidebar sub-panel and its `create new game` button — are hidden from
free-tier sessions in the lobby shell; the
`VITE_GALAXY_DEV_AFFORDANCES` build flag overrides the UI gate so the
owner can exercise both branches from a single test account in DEV
bundles. Admin-driven public-game creation
([Section 10](#10-administration)) bypasses the tier gate.
Public games are created exclusively through the admin surface Public games are created exclusively through the admin surface
([Section 10](#10-administration)). The user surface never produces a public game; this ([Section 10](#10-administration)). The user surface never produces a public game; this
asymmetry is enforced in backend, not at the route level. asymmetry is enforced in backend, not at the route level.
+12
View File
@@ -377,6 +377,18 @@ cancelled достижим из любого pre-finished-состояния.
visibility `private`, в состоянии `draft`, с конфигурацией из visibility `private`, в состоянии `draft`, с конфигурацией из
тела запроса в качестве начальных значений. тела запроса в качестве начальных значений.
User-surface гейтится платным тарифом вызывающего. Backend читает
`EntitlementProvider.IsPaid(userID)` перед вызовом lobby-сервиса;
free-tier-вызовы отклоняются с HTTP `403 forbidden`
(канонический код ошибки `forbidden`), и `draft`-запись не
создаётся. Соответствующие UI-аффордансы — подраздел
`private games` в сайдбаре и кнопка `create new game` внутри него —
скрыты в lobby-shell для free-tier-сессий; build-флаг
`VITE_GALAXY_DEV_AFFORDANCES` переопределяет UI-гейт, чтобы owner
мог в DEV-сборке проверять обе ветки с одного тестового аккаунта.
Admin-создание public-игр ([Раздел 10](#10-администрирование))
обходит тир-гейт.
Public-игры создаются исключительно через admin-surface Public-игры создаются исключительно через admin-surface
([Раздел 10](#10-администрирование)). User-surface никогда не ([Раздел 10](#10-администрирование)). User-surface никогда не
производит public-игру; асимметрия enforced в backend, не на производит public-игру; асимметрия enforced в backend, не на
@@ -48,6 +48,7 @@ func TestAdminGlobalGamesView(t *testing.T) {
// Two users; user A creates a private game. // Two users; user A creates a private game.
a := testenv.RegisterSession(t, plat, "ownerA@example.com") a := testenv.RegisterSession(t, plat, "ownerA@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, a)
b := testenv.RegisterSession(t, plat, "ownerB@example.com") b := testenv.RegisterSession(t, plat, "ownerB@example.com")
aID, err := a.LookupUserID(ctx, plat) aID, err := a.LookupUserID(ctx, plat)
if err != nil { if err != nil {
+1
View File
@@ -29,6 +29,7 @@ func TestEngineCommandProxy(t *testing.T) {
} }
owner := testenv.RegisterSession(t, plat, "owner+cmd@example.com") owner := testenv.RegisterSession(t, plat, "owner+cmd@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
ownerID, err := owner.LookupUserID(ctx, plat) ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil { if err != nil {
t.Fatalf("resolve owner: %v", err) t.Fatalf("resolve owner: %v", err)
+1
View File
@@ -38,6 +38,7 @@ func TestLobbyFlow_PrivateGameInviteRedeem(t *testing.T) {
} }
owner := testenv.RegisterSession(t, plat, "owner+lobby@example.com") owner := testenv.RegisterSession(t, plat, "owner+lobby@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
invitee := testenv.RegisterSession(t, plat, "invitee+lobby@example.com") invitee := testenv.RegisterSession(t, plat, "invitee+lobby@example.com")
ownerID, err := owner.LookupUserID(ctx, plat) ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil { if err != nil {
+61
View File
@@ -0,0 +1,61 @@
package integration_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"galaxy/integration/testenv"
)
// TestLobbyFlow_FreeUserCreateGameForbidden asserts the F8-04b backend
// tier gate: a freshly registered (free-tier) account is rejected with
// `403 forbidden` when it tries to create a private game through the
// user-facing surface. The matching paid sibling
// `TestLobbyFlow_PrivateGameInviteRedeem` covers the success path with
// `testenv.PromoteToPaid`.
func TestLobbyFlow_FreeUserCreateGameForbidden(t *testing.T) {
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
owner := testenv.RegisterSession(t, plat, "owner+free@example.com")
ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil {
t.Fatalf("resolve owner: %v", err)
}
ownerHTTP := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID)
gameBody := map[string]any{
"game_name": "Free Tier Game",
"visibility": "private",
"min_players": 2,
"max_players": 4,
"start_gap_hours": 1,
"start_gap_players": 2,
"enrollment_ends_at": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
"turn_schedule": "0 * * * *",
"target_engine_version": "v1.0.0",
}
raw, resp, err := ownerHTTP.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games", gameBody)
if err != nil {
t.Fatalf("create private game: %v", err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 forbidden, got status=%d body=%s", resp.StatusCode, string(raw))
}
var envelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode error envelope: %v body=%s", err, string(raw))
}
if envelope.Error.Code != "forbidden" {
t.Fatalf("expected code=forbidden, got %q body=%s", envelope.Error.Code, string(raw))
}
}
+1
View File
@@ -31,6 +31,7 @@ func TestLobbyMyGamesList(t *testing.T) {
} }
owner := testenv.RegisterSession(t, plat, "owner+mygames@example.com") owner := testenv.RegisterSession(t, plat, "owner+mygames@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
pilot := testenv.RegisterSession(t, plat, "pilot+mygames@example.com") pilot := testenv.RegisterSession(t, plat, "pilot+mygames@example.com")
ownerID, err := owner.LookupUserID(ctx, plat) ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil { if err != nil {
@@ -29,6 +29,7 @@ func TestLobbyOpenEnrollment(t *testing.T) {
} }
owner := testenv.RegisterSession(t, plat, "owner+enroll@example.com") owner := testenv.RegisterSession(t, plat, "owner+enroll@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
other := testenv.RegisterSession(t, plat, "other+enroll@example.com") other := testenv.RegisterSession(t, plat, "other+enroll@example.com")
ownerID, err := owner.LookupUserID(ctx, plat) ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil { if err != nil {
+1
View File
@@ -28,6 +28,7 @@ func TestNotificationFlow_LobbyInvite(t *testing.T) {
} }
inviter := testenv.RegisterSession(t, plat, "inviter@example.com") inviter := testenv.RegisterSession(t, plat, "inviter@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, inviter)
invitee := testenv.RegisterSession(t, plat, "invitee@example.com") invitee := testenv.RegisterSession(t, plat, "invitee@example.com")
inviterUser, err := inviter.LookupUserID(ctx, plat) inviterUser, err := inviter.LookupUserID(ctx, plat)
if err != nil { if err != nil {
+1
View File
@@ -31,6 +31,7 @@ func TestRuntimeLifecycle(t *testing.T) {
} }
owner := testenv.RegisterSession(t, plat, "owner+runtime@example.com") owner := testenv.RegisterSession(t, plat, "owner+runtime@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
ownerID, err := owner.LookupUserID(ctx, plat) ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil { if err != nil {
t.Fatalf("resolve owner: %v", err) t.Fatalf("resolve owner: %v", err)
+29
View File
@@ -92,6 +92,35 @@ func (s *Session) DialAuthenticated(ctx context.Context, plat *Platform) (*Signe
return DialGateway(ctx, plat.Gateway.GRPCAddr, s.DeviceSessionID, s.Private, plat.Gateway.ResponseSignerPublic) return DialGateway(ctx, plat.Gateway.GRPCAddr, s.DeviceSessionID, s.Private, plat.Gateway.ResponseSignerPublic)
} }
// PromoteToPaid applies a permanent paid entitlement to the user
// behind sess via the backend admin surface, so subsequent lobby
// commands gated by `EntitlementProvider.IsPaid` (notably
// `POST /api/v1/user/lobby/games`) succeed. Helper for integration
// scenarios that create games end-to-end; the default
// `RegisterSession` leaves the user on the free tier.
func PromoteToPaid(t *testing.T, ctx context.Context, admin *BackendAdminClient, plat *Platform, sess *Session) {
t.Helper()
if sess == nil {
t.Fatalf("PromoteToPaid: nil session")
}
userID, err := sess.LookupUserID(ctx, plat)
if err != nil {
t.Fatalf("PromoteToPaid: lookup user_id: %v", err)
}
body := map[string]any{
"tier": "permanent",
"source": "integration_test",
"actor": map[string]any{"type": "admin", "id": "integration"},
}
raw, resp, err := admin.Do(ctx, http.MethodPost, "/api/v1/admin/users/"+userID+"/entitlements", body)
if err != nil {
t.Fatalf("PromoteToPaid: %v", err)
}
if resp.StatusCode/100 != 2 {
t.Fatalf("PromoteToPaid: status=%d body=%s", resp.StatusCode, string(raw))
}
}
// LookupUserID resolves the user_id for s via backend's internal // LookupUserID resolves the user_id for s via backend's internal
// session lookup. Returns an empty string if the session is unknown. // session lookup. Returns an empty string if the session is unknown.
func (s *Session) LookupUserID(ctx context.Context, plat *Platform) (string, error) { func (s *Session) LookupUserID(ctx context.Context, plat *Platform) (string, error) {
+160 -72
View File
@@ -3,30 +3,40 @@
The lobby is the first authenticated view; the user lands here after The lobby is the first authenticated view; the user lands here after
the email-code login completes (see the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared [`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. defaults baked into the create-game form.
## Shell ## Shell
Lobby and profile share a single chrome implemented in Lobby pages, profile, and the synthetic-reports loader share a single
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project chrome implemented in `lib/screens/lobby-shell.svelte`. The chrome
site's VitePress layout: a left page-list sidebar (Overview / mirrors the project site's VitePress layout: a two-level left sidebar,
Profile), a top identity strip on the right, and the page content in a top identity strip on the right, and the page content in the
the right-hand column. The shell uses `var(--font-mono)` so the right-hand column. The shell uses `var(--font-mono)` so the post-login
post-login pages adopt the "nerdy" type stack that the public site pages adopt the "nerdy" type stack that the public site already uses.
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 The identity strip reads the caller's account from
`lib/account-store.svelte.ts` — a session-wide cache that fetches `lib/account-store.svelte.ts` — a session-wide cache that fetches
`user.account.get` once on first access and is written through after `user.account.get` once on first access and is written through after
every Profile save. Both `lobby-screen.svelte` and every Profile save. Every sub-screen populates the same cache through
`profile-screen.svelte` populate the same cache through `account.ensure(client)`, so navigating between panels never re-issues
`account.ensure(client)`, so switching Overview ⇄ Profile never `user.account.get` and the strip never flashes the
re-issues `user.account.get` and the strip never flashes the
`lobby.account_loading` placeholder mid-navigation. The cache is `lobby.account_loading` placeholder mid-navigation. The cache is
cleared by `session.signOut("user")` / `signOut("revoked")` so a cleared by `session.signOut("user")` / `signOut("revoked")` so a
different user signing in on the same browser does not briefly see 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` The strip falls back to `display_name` → immutable `user_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It `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 lobby-loaded signal. The logout button sits next to it
(`session.signOut("user")`). (`session.signOut("user")`).
The sidebar always renders both pages; clicking the active page is a Clicking the active item is a no-op (mirrors the F8-02 idiom from
no-op. The shell collapses to a horizontal scrolling strip below issue #45). The sidebar collapses to a horizontal scrolling strip
640px. 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. The `games` parent expands into a submenu in the canonical order
Cards inside each section take the full available width. 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 | | Sub-panel | Source | Visibility |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | | --------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) | | `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). |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) | | `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. |
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) | | `invitations` | `lobby.my.invites.list` (status=`pending`) | Always visible. |
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `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. |
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
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 ## Profile sub-screen
`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of `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, same as the create screen — pushing a fresh history entry on entry,
falling back to lobby on Back/Forward (see falling back to lobby on Back/Forward (see
[`navigation.md`](navigation.md)). [`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 profile** and surfaces a transient `profile-saved-notice` line
(`data-testid="profile-saved-notice"`). Editing any field clears the (`data-testid="profile-saved-notice"`). Editing any field clears the
notice. Only the explicit `cancel` button navigates back to the lobby 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 the UI also ships translations for, the active i18n locale switches
in-place so the rest of the session matches the new preference. The 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 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 without an additional gateway call. See
[`game-state.md`](game-state.md) for the consumer's view. [`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 ## Create-game form
The form posts `lobby.game.create` through the gateway with 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` | | | `start_gap_players` | Advanced toggle | `2` | |
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank | | `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
On success the create screen returns to the lobby On success the create screen navigates to the `games-private-games`
(`appScreen.go("lobby")`) and the new game shows up in `my games` sub-panel so the freshly-created game shows up immediately (the
once the lobby's onMount has had a chance to refresh the list (the lobby-data store refreshes on the next sub-panel mount). On failure
lobby screen remounts on return, so its onMount re-fires). 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 ## Errors
Lobby errors raised by the gateway carry a canonical code Lobby errors raised by the gateway carry a canonical code
(`invalid_request`, `subject_not_found`, `forbidden`, `conflict`, (`invalid_request`, `subject_not_found`, `forbidden`, `conflict`,
`internal_error`). The `LobbyError` thrown by `lobby.ts` exposes the `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 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 ## Why FlatBuffers on the TS side
@@ -172,5 +259,6 @@ schema. The TS integration ships:
binding drift in CI. binding drift in CI.
`user.account.get` decodes through the generated `AccountResponse` `user.account.get` decodes through the generated `AccountResponse`
table, so the lobby greeting works against a real local stack as well table the lobby greeting works against a real local stack as well
as the mocked Playwright fixtures. 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 `/__debug/*` surfaces. What the URL used to encode now lives in two
rune singletons in `src/lib/app-nav.svelte.ts`: rune singletons in `src/lib/app-nav.svelte.ts`:
- **`appScreen`** — the top-level screen - **`appScreen`** — the top-level screen plus the active `gameId`. The
(`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the literal values are:
active `gameId`. It replaces the old `goto`-based redirects and the - `login` — anonymous entry point
`[id]` route param. - `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` / - **`activeView`** — the in-game view (`map` / `table` / `report` /
`battle` / `mail` / `designer-science`) plus the sub-parameters the `battle` / `mail` / `designer-science`) plus the sub-parameters the
old route segments carried (`tableEntity`, `battleId`, `turn`, 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 render: it gates on `session.status` (anonymous → login, authenticated
→ the `appScreen.screen`), and for the authenticated tree mounts the → the `appScreen.screen`), and for the authenticated tree mounts the
matching screen component from `src/lib/screens/` matching screen component from `src/lib/screens/`
(`login-screen.svelte`, `lobby-screen.svelte`, (`login-screen.svelte`, `lobby-screen.svelte` resolver,
`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for `lobby-create-screen.svelte`, `profile-screen.svelte`,
`screen === "game"`, the in-game shell `games-active-past-screen.svelte`, `games-recruitment-screen.svelte`,
`src/lib/game/game-shell.svelte`. Lobby and profile share a `games-invitations-screen.svelte`,
post-login chrome (sidebar + identity strip) implemented in `games-private-games-screen.svelte`,
`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders `synthetic-reports-screen.svelte`) or, for `screen === "game"`, the
the active view from `activeView` (see below). Navigation is 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, `appScreen.go(screen, { gameId })` and `activeView.select(view,
params)` — never `goto`. 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 ### Active-view dispatch
The client renders **one active view at a time**. The game shell The client renders **one active view at a time**. The game shell
+19 -1
View File
@@ -40,6 +40,16 @@ export interface Account {
preferredLanguage: string; preferredLanguage: string;
timeZone: string; timeZone: string;
declaredCountry: string; declaredCountry: string;
entitlement: AccountEntitlement;
}
// AccountEntitlement is the narrow view of the FBS EntitlementSnapshot
// the UI currently consumes. `isPaid` gates lobby affordances tied to
// the paid tier (F8-04b: private-games subpage + create-game button).
// Other snapshot fields (plan code, expiry timestamps) are intentionally
// omitted until a feature needs them.
export interface AccountEntitlement {
isPaid: boolean;
} }
export async function getMyAccount(client: GalaxyClient): Promise<Account> { export async function getMyAccount(client: GalaxyClient): Promise<Account> {
@@ -119,7 +129,12 @@ function decodeAccountResponse(payload: Uint8Array): Account {
return decodeAccountView(view); return decodeAccountView(view);
} }
function decodeAccountView(view: AccountView): Account { // Exported for unit tests that build a synthetic AccountView via the
// FBS bindings and assert the resulting Account shape. Runtime callers
// reach the same decode path through `getMyAccount` / `updateMyProfile`
// / `updateMySettings`.
export function decodeAccountView(view: AccountView): Account {
const entitlement = view.entitlement();
return { return {
userId: view.userId() ?? "", userId: view.userId() ?? "",
email: view.email() ?? "", email: view.email() ?? "",
@@ -128,5 +143,8 @@ function decodeAccountView(view: AccountView): Account {
preferredLanguage: view.preferredLanguage() ?? "", preferredLanguage: view.preferredLanguage() ?? "",
timeZone: view.timeZone() ?? "", timeZone: view.timeZone() ?? "",
declaredCountry: view.declaredCountry() ?? "", declaredCountry: view.declaredCountry() ?? "",
entitlement: {
isPaid: entitlement?.isPaid() ?? false,
},
}; };
} }
+11 -1
View File
@@ -7,7 +7,17 @@ declare global {
// (and active game) live in `page.state` so browser Back/Forward // (and active game) live in `page.state` so browser Back/Forward
// move between screens while the address bar stays at /game/. // move between screens while the address bar stays at /game/.
interface PageState { interface PageState {
screen?: "login" | "lobby" | "lobby-create" | "profile" | "game"; screen?:
| "login"
| "lobby"
| "lobby-create"
| "profile"
| "game"
| "games-active-past"
| "games-recruitment"
| "games-invitations"
| "games-private-games"
| "synthetic-reports";
gameId?: string | null; gameId?: string | null;
} }
} }
+42 -1
View File
@@ -22,7 +22,43 @@
import { pushState, replaceState } from "$app/navigation"; import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game"; // Top-level app-shell screens. The lobby is split into per-page screens
// (F8-04b): `lobby` is the bare alias the shell resolves to the first
// visible games sub-page; the explicit `games-*` literals point at one
// of the four lobby sub-panels; `synthetic-reports` is the DEV-only
// reports screen (build-time gated via VITE_GALAXY_DEV_AFFORDANCES).
// `lobby-create` and `profile` remain as separate top-level screens.
export type AppScreen =
| "login"
| "lobby"
| "lobby-create"
| "profile"
| "game"
| "games-active-past"
| "games-recruitment"
| "games-invitations"
| "games-private-games"
| "synthetic-reports";
// LOBBY_SUB_SCREENS lists the AppScreen literals that the sidebar
// renders as members of the `games` submenu. The order is the canonical
// "first-visible" order: when the user clicks the `games` header (or
// the screen state is the bare `lobby` alias) the shell picks the
// first entry whose visibility predicate holds.
export const LOBBY_SUB_SCREENS: readonly AppScreen[] = [
"games-active-past",
"games-recruitment",
"games-invitations",
"games-private-games",
];
// isLobbySubScreen returns true when screen identifies one of the
// `games-*` sub-panels — useful for the sidebar to highlight the
// `games` parent and to decide whether the desktop submenu should be
// rendered expanded.
export function isLobbySubScreen(screen: AppScreen): boolean {
return LOBBY_SUB_SCREENS.includes(screen);
}
export type GameView = export type GameView =
| "map" | "map"
@@ -53,6 +89,11 @@ const APP_SCREENS: readonly AppScreen[] = [
"lobby-create", "lobby-create",
"profile", "profile",
"game", "game",
"games-active-past",
"games-recruitment",
"games-invitations",
"games-private-games",
"synthetic-reports",
]; ];
const GAME_VIEWS: readonly GameView[] = [ const GAME_VIEWS: readonly GameView[] = [
"map", "map",
+17
View File
@@ -55,16 +55,31 @@ const en = {
"lobby.nav.aria_label": "lobby pages", "lobby.nav.aria_label": "lobby pages",
"lobby.nav.overview": "Overview", "lobby.nav.overview": "Overview",
"lobby.nav.profile": "Profile", "lobby.nav.profile": "Profile",
"lobby.nav.games": "games",
"lobby.nav.games.active_past": "active & past",
"lobby.nav.games.recruitment": "recruitment",
"lobby.nav.games.invitations": "invitations",
"lobby.nav.games.private_games": "private games",
"lobby.nav.games.aria_label": "games sections",
"lobby.nav.games.mobile_toggle": "games · {label}",
"lobby.nav.synthetic_reports": "Synthetic test reports",
"lobby.section.my_games": "my games", "lobby.section.my_games": "my games",
"lobby.section.invitations": "pending invitations", "lobby.section.invitations": "pending invitations",
"lobby.section.applications": "my applications", "lobby.section.applications": "my applications",
"lobby.section.public_games": "public games", "lobby.section.public_games": "public games",
"lobby.section.recruitment": "open recruitment",
"lobby.section.private_games": "my private games",
"lobby.section.create": "create a game", "lobby.section.create": "create a game",
"lobby.create_button": "create new game", "lobby.create_button": "create new game",
"lobby.my_games.empty": "no games yet", "lobby.my_games.empty": "no games yet",
"lobby.invitations.empty": "no invitations", "lobby.invitations.empty": "no invitations",
"lobby.applications.empty": "no applications", "lobby.applications.empty": "no applications",
"lobby.public_games.empty": "no public games", "lobby.public_games.empty": "no public games",
"lobby.games.active_past.empty": "no active or past games",
"lobby.games.private_games.empty": "no private games yet",
"lobby.recruitment.empty": "no open recruitment",
"lobby.recruitment.applied_pending": "your application is awaiting approval",
"lobby.recruitment.applied_approved": "your application was accepted",
"lobby.invitation.accept": "accept", "lobby.invitation.accept": "accept",
"lobby.invitation.decline": "decline", "lobby.invitation.decline": "decline",
"lobby.application.submit": "submit application", "lobby.application.submit": "submit application",
@@ -96,6 +111,8 @@ const en = {
"lobby.create.game_name_required": "game name must not be empty", "lobby.create.game_name_required": "game name must not be empty",
"lobby.create.turn_schedule_required": "turn schedule must not be empty", "lobby.create.turn_schedule_required": "turn schedule must not be empty",
"lobby.create.enrollment_ends_at_required": "enrollment end time must be set", "lobby.create.enrollment_ends_at_required": "enrollment end time must be set",
"lobby.create.error.forbidden":
"Game creation is available only on a paid plan.",
"lobby.error.invalid_request": "request is invalid", "lobby.error.invalid_request": "request is invalid",
"lobby.error.subject_not_found": "not found", "lobby.error.subject_not_found": "not found",
"lobby.error.forbidden": "operation is forbidden", "lobby.error.forbidden": "operation is forbidden",
+17
View File
@@ -56,16 +56,31 @@ const ru: Record<keyof typeof en, string> = {
"lobby.nav.aria_label": "разделы лобби", "lobby.nav.aria_label": "разделы лобби",
"lobby.nav.overview": "Обзор", "lobby.nav.overview": "Обзор",
"lobby.nav.profile": "Профиль", "lobby.nav.profile": "Профиль",
"lobby.nav.games": "партии",
"lobby.nav.games.active_past": "активные и прошедшие",
"lobby.nav.games.recruitment": "набор",
"lobby.nav.games.invitations": "приглашения",
"lobby.nav.games.private_games": "приватные партии",
"lobby.nav.games.aria_label": "подразделы партий",
"lobby.nav.games.mobile_toggle": "партии · {label}",
"lobby.nav.synthetic_reports": "Synthetic-отчёты",
"lobby.section.my_games": "мои игры", "lobby.section.my_games": "мои игры",
"lobby.section.invitations": "ожидающие приглашения", "lobby.section.invitations": "ожидающие приглашения",
"lobby.section.applications": "мои заявки", "lobby.section.applications": "мои заявки",
"lobby.section.public_games": "публичные игры", "lobby.section.public_games": "публичные игры",
"lobby.section.recruitment": "открытый набор",
"lobby.section.private_games": "мои приватные партии",
"lobby.section.create": "создать игру", "lobby.section.create": "создать игру",
"lobby.create_button": "создать новую игру", "lobby.create_button": "создать новую игру",
"lobby.my_games.empty": "пока нет игр", "lobby.my_games.empty": "пока нет игр",
"lobby.invitations.empty": "приглашений нет", "lobby.invitations.empty": "приглашений нет",
"lobby.applications.empty": "заявок нет", "lobby.applications.empty": "заявок нет",
"lobby.public_games.empty": "публичных игр нет", "lobby.public_games.empty": "публичных игр нет",
"lobby.games.active_past.empty": "нет активных или прошедших партий",
"lobby.games.private_games.empty": "у вас нет собственных партий",
"lobby.recruitment.empty": "набор в партии ещё не открыт",
"lobby.recruitment.applied_pending": "ваша заявка ожидает одобрения",
"lobby.recruitment.applied_approved": "ваша заявка принята",
"lobby.invitation.accept": "принять", "lobby.invitation.accept": "принять",
"lobby.invitation.decline": "отклонить", "lobby.invitation.decline": "отклонить",
"lobby.application.submit": "подать заявку", "lobby.application.submit": "подать заявку",
@@ -97,6 +112,8 @@ const ru: Record<keyof typeof en, string> = {
"lobby.create.game_name_required": "название игры не должно быть пустым", "lobby.create.game_name_required": "название игры не должно быть пустым",
"lobby.create.turn_schedule_required": "расписание ходов не должно быть пустым", "lobby.create.turn_schedule_required": "расписание ходов не должно быть пустым",
"lobby.create.enrollment_ends_at_required": "время окончания набора обязательно", "lobby.create.enrollment_ends_at_required": "время окончания набора обязательно",
"lobby.create.error.forbidden":
"Создание партий доступно только на платном тарифе.",
"lobby.error.invalid_request": "запрос некорректен", "lobby.error.invalid_request": "запрос некорректен",
"lobby.error.subject_not_found": "объект не найден", "lobby.error.subject_not_found": "объект не найден",
"lobby.error.forbidden": "операция запрещена", "lobby.error.forbidden": "операция запрещена",
+177
View File
@@ -0,0 +1,177 @@
// LobbyDataStore is the session-wide cache for the four lobby panels
// (active-past / recruitment / invitations / private-games). It owns the
// GalaxyClient instance used by lobby HTTP commands, the result of the
// `lobby.*.list` fan-out, and the loading / error flags every panel
// reads. Sub-screens that need to mutate (submit application, redeem
// invite) go through the store so the optimistic state stays consistent
// across navigations.
//
// The store is built around F8-04b's split of the old single
// `lobby-screen.svelte` into per-panel screens — the prior design fetched
// everything on every panel mount, and refetching on each navigation
// flash-cleared the UI. A singleton with $state runes keeps the four
// lists alive while the user moves between subpages.
//
// `clear()` resets the store on signOut; the matching plumbing lives in
// `session-store.svelte.ts::signOut`.
import { createGatewayClient } from "../api/connect";
import { GalaxyClient } from "../api/galaxy-client";
import {
LobbyError,
listMyApplications,
listMyGames,
listMyInvites,
listPublicGames,
type ApplicationSummary,
type GameSummary,
type InviteSummary,
} from "../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "./env";
import { i18n, type TranslationKey } from "./i18n/index.svelte";
import { loadCore } from "../platform/core/index";
import { session } from "./session-store.svelte";
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
export function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) {
return translated;
}
return i18n.t("lobby.error.unknown", { message: err.message });
}
return err instanceof Error ? err.message : "request failed";
}
class LobbyDataStore {
myGames = $state<GameSummary[]>([]);
invitations = $state<InviteSummary[]>([]);
applications = $state<ApplicationSummary[]>([]);
publicGames = $state<GameSummary[]>([]);
loading = $state(true);
error: string | null = $state(null);
configError: string | null = $state(null);
#client: GalaxyClient | null = null;
#bootstrap: Promise<GalaxyClient | null> | null = null;
#refresh: Promise<void> | null = null;
get client(): GalaxyClient | null {
return this.#client;
}
// ensure resolves to the cached GalaxyClient, building one on first
// call and triggering the initial `lobby.*.list` fan-out. Concurrent
// callers from sibling screens share the same in-flight bootstrap.
ensure(): Promise<GalaxyClient | null> {
if (this.#client !== null) {
return Promise.resolve(this.#client);
}
if (this.#bootstrap !== null) {
return this.#bootstrap;
}
this.#bootstrap = this.#bootstrapClient();
return this.#bootstrap;
}
async #bootstrapClient(): Promise<GalaxyClient | null> {
try {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
this.loading = false;
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
this.configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return null;
}
const keypair = session.keypair;
const core = await loadCore();
this.#client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await this.refresh();
return this.#client;
} catch (err) {
this.error = describeLobbyError(err);
this.loading = false;
return null;
} finally {
this.#bootstrap = null;
}
}
// refresh re-runs the four `lobby.*.list` fan-out. Concurrent callers
// share the same in-flight promise.
refresh(): Promise<void> {
if (this.#client === null) {
return Promise.resolve();
}
if (this.#refresh !== null) {
return this.#refresh;
}
const client = this.#client;
this.loading = true;
this.error = null;
this.#refresh = (async () => {
try {
const [games, invites, apps, publicPage] = await Promise.all([
listMyGames(client),
listMyInvites(client),
listMyApplications(client),
listPublicGames(client),
]);
this.myGames = games;
this.invitations = invites.filter((invite) => invite.status === "pending");
this.applications = apps;
this.publicGames = publicPage.items;
} catch (err) {
this.error = describeLobbyError(err);
} finally {
this.loading = false;
this.#refresh = null;
}
})();
return this.#refresh;
}
prependApplication(app: ApplicationSummary): void {
this.applications = [app, ...this.applications];
}
removeInvitation(inviteId: string): void {
this.invitations = this.invitations.filter((i) => i.inviteId !== inviteId);
}
setMyGames(games: GameSummary[]): void {
this.myGames = games;
}
clear(): void {
this.#client = null;
this.#bootstrap = null;
this.#refresh = null;
this.myGames = [];
this.invitations = [];
this.applications = [];
this.publicGames = [];
this.loading = true;
this.error = null;
this.configError = null;
}
}
export const lobbyData = new LobbyDataStore();
@@ -0,0 +1,111 @@
<!--
Active & past games panel for the lobby `games` section. Lists every
game where the caller is a member, regardless of lifecycle status.
Statuses that have no navigable in-game view (draft / enrollment_open /
starting / cancelled / start_failed) render as disabled cards.
The shell hides this submenu item entirely when the player has no games,
so the empty-state text below is reached only in the narrow window
where the shell mounts before the lobby-data fan-out resolves.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
function gotoGame(gameId: string): void {
activeView.reset();
appScreen.go("game", { gameId });
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-active-past-section">
<h2>{i18n.t("lobby.section.my_games")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if lobbyData.myGames.length === 0}
<p data-testid="lobby-games-active-past-empty">
{i18n.t("lobby.games.active_past.empty")}
</p>
{:else}
<ul class="card-list">
{#each lobbyData.myGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section {
margin-bottom: var(--space-6);
}
section h2 {
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
.meta {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,127 @@
<!--
Pending invitations panel for the lobby `games` section. Surfaces
user-bound invites that have not been redeemed or declined yet, with
accept / decline actions. Accepted invites move the inviting game into
`active-past`; declined invites disappear from the list.
-->
<script lang="ts">
import { onMount } from "svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData, describeLobbyError } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
declineInvite,
listMyGames,
redeemInvite,
type InviteSummary,
} from "../../api/lobby";
let actionInFlight = $state<string | null>(null);
let actionError = $state<string | null>(null);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
async function acceptInvite(invite: InviteSummary): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
actionInFlight = invite.inviteId;
actionError = null;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
lobbyData.removeInvitation(invite.inviteId);
lobbyData.setMyGames(await listMyGames(client));
} catch (err) {
actionError = describeLobbyError(err);
} finally {
actionInFlight = null;
}
}
async function rejectInvite(invite: InviteSummary): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
actionInFlight = invite.inviteId;
actionError = null;
try {
await declineInvite(client, invite.gameId, invite.inviteId);
lobbyData.removeInvitation(invite.inviteId);
} catch (err) {
actionError = describeLobbyError(err);
} finally {
actionInFlight = null;
}
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
{#if actionError !== null}
<p role="alert" data-testid="lobby-invitation-error">{actionError}</p>
{/if}
<section data-testid="lobby-games-invitations-section">
<h2>{i18n.t("lobby.section.invitations")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if lobbyData.invitations.length === 0}
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
{:else}
<ul class="card-list">
{#each lobbyData.invitations as invite (invite.inviteId)}
<li class="card">
<strong>{invite.raceName}</strong>
<span class="meta">{invite.gameId}</span>
<div class="actions">
<button
onclick={() => acceptInvite(invite)}
disabled={actionInFlight === invite.inviteId}
data-testid="lobby-invite-accept"
>
{i18n.t("lobby.invitation.accept")}
</button>
<button
onclick={() => rejectInvite(invite)}
disabled={actionInFlight === invite.inviteId}
data-testid="lobby-invite-decline"
>
{i18n.t("lobby.invitation.decline")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
width: 100%;
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
</style>
@@ -0,0 +1,137 @@
<!--
Private games panel for the lobby `games` section. Filters
`lobby.my.games.list` down to games where the caller is the owner and
the visibility is `private`. The right-hand corner of the panel hosts
the "create new game" button that opens `lobby-create`.
This subpage is gated by paid-tier visibility (the shell hides the
sidebar entry on free-tier; the backend also rejects
`lobby.game.create` with `403 forbidden` for free callers). The
`VITE_GALAXY_DEV_AFFORDANCES` flag is the DEV bypass — the shell
keeps the entry visible so the owner can exercise both paths from a
single test account.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
function gotoGame(gameId: string): void {
activeView.reset();
appScreen.go("game", { gameId });
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
let privateGames = $derived.by(() => {
const me = account.current?.userId ?? "";
return lobbyData.myGames.filter(
(g) => g.ownerUserId === me && g.gameType === "private",
);
});
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-private-section">
<header class="section-header">
<h2>{i18n.t("lobby.section.private_games")}</h2>
<button
type="button"
class="create-button"
onclick={gotoCreate}
data-testid="lobby-create-button"
>
{i18n.t("lobby.create_button")}
</button>
</header>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if privateGames.length === 0}
<p data-testid="lobby-games-private-empty">
{i18n.t("lobby.games.private_games.empty")}
</p>
{:else}
<ul class="card-list">
{#each privateGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-private-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.create-button {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
cursor: pointer;
}
.create-button:hover { background: var(--color-surface-hover); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
</style>
@@ -0,0 +1,291 @@
<!--
Recruitment panel for the lobby `games` section. Surfaces every public
game open for enrollment plus the caller's own pending/approved
applications for games whose enrollment has since closed. Cards merge
the public-game summary with the caller's application (if any) and
display the status as a chip in the top-right.
Inline race-name form behaviour:
- visible when the caller has no application for that game;
- visible when the caller's last application was rejected (re-apply
flow, owner-confirmed in F8-04b);
- hidden when the application is pending or approved.
Cards for stale applications (enrollment closed, no public-game row):
- pending/approved → rendered as a standalone "applied" card so the
caller can see their waitlist or accepted slot;
- rejected/unknown → not rendered (no actionable info).
-->
<script lang="ts">
import { onMount } from "svelte";
import { account } from "$lib/account-store.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
lobbyData,
describeLobbyError,
} from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
submitApplication,
type ApplicationSummary,
type GameSummary,
} from "../../api/lobby";
let openApplicationFor = $state<string | null>(null);
let raceNameInput = $state("");
let raceNameError = $state<string | null>(null);
let submitting = $state(false);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function applicationStatusLabel(status: string): string {
const key = `lobby.application.status.${status}` as TranslationKey;
const translated = i18n.t(key);
if (translated === key) {
return i18n.t("lobby.application.status.unknown", { status });
}
return translated;
}
// Determine whether the inline race-name form should be visible for
// a given (game, application) pair. Visible when there's no
// application, or when the latest application was rejected so the
// caller can try again. Hidden for pending / approved / unknown.
function showApplicationForm(application: ApplicationSummary | null): boolean {
if (application === null) return true;
return application.status === "rejected";
}
interface RecruitmentCard {
game: GameSummary;
application: ApplicationSummary | null;
}
let appsByGameId = $derived.by<Map<string, ApplicationSummary>>(() => {
const m = new Map<string, ApplicationSummary>();
for (const app of lobbyData.applications) {
m.set(app.gameId, app);
}
return m;
});
let recruitmentCards = $derived.by<ReadonlyArray<RecruitmentCard>>(() => {
const me = account.current?.userId ?? "";
return lobbyData.publicGames
.filter((g) => g.ownerUserId !== me)
.map((g) => ({ game: g, application: appsByGameId.get(g.gameId) ?? null }));
});
// Standalone cards for stale applications (game no longer in the
// public-games list but the application is pending/approved).
// Rejected / unknown stale applications are intentionally hidden.
let standaloneApplications = $derived.by<ReadonlyArray<ApplicationSummary>>(() => {
const seen = new Set(lobbyData.publicGames.map((g) => g.gameId));
return lobbyData.applications.filter(
(app) =>
!seen.has(app.gameId) &&
(app.status === "pending" || app.status === "approved"),
);
});
function openApplicationForm(gameId: string): void {
openApplicationFor = gameId;
raceNameInput = "";
raceNameError = null;
}
function cancelApplicationForm(): void {
openApplicationFor = null;
raceNameInput = "";
raceNameError = null;
}
async function submitApplicationFor(gameId: string): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
const trimmed = raceNameInput.trim();
if (trimmed === "") {
raceNameError = i18n.t("lobby.application.race_name_required");
return;
}
submitting = true;
raceNameError = null;
try {
const result = await submitApplication(client, gameId, trimmed);
lobbyData.prependApplication(result);
openApplicationFor = null;
raceNameInput = "";
} catch (err) {
raceNameError = describeLobbyError(err);
} finally {
submitting = false;
}
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-recruitment-section">
<h2>{i18n.t("lobby.section.recruitment")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if recruitmentCards.length === 0 && standaloneApplications.length === 0}
<p data-testid="lobby-recruitment-empty">
{i18n.t("lobby.recruitment.empty")}
</p>
{:else}
<ul class="card-list">
{#each recruitmentCards as card (card.game.gameId)}
<li class="card" data-testid="lobby-recruitment-card">
<div class="card-header">
<strong>{card.game.gameName}</strong>
{#if card.application !== null}
<span
class="status-chip"
data-status={card.application.status}
data-testid="lobby-application-status-chip"
>
{applicationStatusLabel(card.application.status)}
</span>
{/if}
</div>
<span class="meta">{card.game.status}</span>
<span class="meta">{card.game.minPlayers}{card.game.maxPlayers} players</span>
{#if showApplicationForm(card.application)}
{#if openApplicationFor === card.game.gameId}
<form
onsubmit={(event) => {
event.preventDefault();
submitApplicationFor(card.game.gameId);
}}
data-testid="lobby-application-form"
>
<label>
{i18n.t("lobby.application.race_name_label")}
<input
type="text"
bind:value={raceNameInput}
data-testid="lobby-application-race-name"
autocomplete="off"
/>
</label>
{#if raceNameError !== null}
<p role="alert" data-testid="lobby-application-error">
{raceNameError}
</p>
{/if}
<div class="actions">
<button
type="submit"
disabled={submitting}
data-testid="lobby-application-submit"
>
{i18n.t("lobby.application.submit")}
</button>
<button
type="button"
onclick={cancelApplicationForm}
data-testid="lobby-application-cancel"
>
{i18n.t("lobby.application.cancel")}
</button>
</div>
</form>
{:else}
<button
onclick={() => openApplicationForm(card.game.gameId)}
data-testid="lobby-public-game-apply"
>
{i18n.t("lobby.application.submit_for", {
name: card.game.gameName,
})}
</button>
{/if}
{:else if card.application?.status === "pending"}
<p class="meta">{i18n.t("lobby.recruitment.applied_pending")}</p>
{:else if card.application?.status === "approved"}
<p class="meta">{i18n.t("lobby.recruitment.applied_approved")}</p>
{/if}
</li>
{/each}
{#each standaloneApplications as app (app.applicationId)}
<li class="card" data-testid="lobby-stale-application-card">
<div class="card-header">
<strong>{app.raceName}</strong>
<span
class="status-chip"
data-status={app.status}
data-testid="lobby-application-status-chip"
>
{applicationStatusLabel(app.status)}
</span>
</div>
<span class="meta">{app.gameId}</span>
{#if app.status === "pending"}
<p class="meta">{i18n.t("lobby.recruitment.applied_pending")}</p>
{:else}
<p class="meta">{i18n.t("lobby.recruitment.applied_approved")}</p>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
width: 100%;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.status-chip {
align-self: flex-start;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface);
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: lowercase;
}
.status-chip[data-status="approved"] {
color: var(--color-accent);
}
.status-chip[data-status="rejected"] {
color: var(--color-danger, #c33);
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
form { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-2); }
label { display: flex; flex-direction: column; gap: var(--space-1); }
input[type="text"] { font: inherit; font-size: var(--text-md); padding: var(--space-1) var(--space-2); }
</style>
@@ -40,6 +40,15 @@
function describeLobbyError(err: unknown): string { function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) { if (err instanceof LobbyError) {
// Free-tier callers reach this branch when the backend gate
// at `lobby.game.create` rejects them. Show the dedicated
// inline message instead of the generic "operation
// forbidden" — the user got to this screen via the
// `private games` panel, so we want to spell out that the
// gate is the tier (not a permission misconfig).
if (err.code === "forbidden") {
return i18n.t("lobby.create.error.forbidden");
}
const key = `lobby.error.${err.code}` as TranslationKey; const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key); const translated = i18n.t(key);
if (translated !== key) { if (translated !== key) {
@@ -93,7 +102,10 @@
turnSchedule: trimmedSchedule, turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION, targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
}); });
appScreen.go("lobby"); // Land on the private-games panel where the freshly created
// game shows up — the lobby-data store will refresh on next
// mount.
appScreen.go("games-private-games");
} catch (err) { } catch (err) {
formError = describeLobbyError(err); formError = describeLobbyError(err);
} finally { } finally {
+24 -536
View File
@@ -1,544 +1,32 @@
<!--
Resolver for the bare `lobby` AppScreen literal. F8-04b split the
old single-page Overview into per-panel screens (`games-active-past`,
`games-recruitment`, `games-invitations`, `games-private-games`); this
component is what the dispatcher renders when the active screen is
the historical `lobby` alias (e.g. a snapshot persisted before the
split, or programmatic `appScreen.go("lobby")` from non-shell code).
The resolver navigates to the first visible games sub-page at mount
time and renders a thin LobbyShell placeholder while the redirect
runs. The destination depends on the account tier and on whether the
caller has any games yet — both are computed by the shell, so we just
pick `games-recruitment` here as the canonical landing (always
visible) and let the shell's submenu logic surface the others.
-->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
LobbyError,
listMyApplications,
listMyGames,
listMyInvites,
listPublicGames,
redeemInvite,
declineInvite,
submitApplication,
type ApplicationSummary,
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { account } from "$lib/account-store.svelte";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import LobbyShell from "./lobby-shell.svelte"; import LobbyShell from "./lobby-shell.svelte";
let configError: string | null = $state(null); onMount(() => {
let listsLoading = $state(true); // The shell's own $effect keeps the user on a valid sub-page if
let lobbyError: string | null = $state(null); // the resolved choice ever becomes invisible; we just kick off
// the redirect on first paint.
let myGames: GameSummary[] = $state([]); appScreen.go("games-recruitment");
let invitations: InviteSummary[] = $state([]);
let applications: ApplicationSummary[] = $state([]);
let publicGames: GameSummary[] = $state([]);
let openApplicationFor: string | null = $state(null);
let raceNameInput = $state("");
let raceNameError: string | null = $state(null);
let submittingApplication = $state(false);
let inviteActionInFlight: string | null = $state(null);
let syntheticError: string | null = $state(null);
let client: GalaxyClient | null = null;
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) {
return translated;
}
return i18n.t("lobby.error.unknown", { message: err.message });
}
return err instanceof Error ? err.message : "request failed";
}
async function refreshAll(): Promise<void> {
if (client === null) return;
listsLoading = true;
lobbyError = null;
try {
const [games, invites, apps, publicPage] = await Promise.all([
listMyGames(client),
listMyInvites(client),
listMyApplications(client),
listPublicGames(client),
]);
myGames = games;
invitations = invites.filter((invite) => invite.status === "pending");
applications = apps;
publicGames = publicPage.items;
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
listsLoading = false;
}
}
function applicationStatusLabel(status: string): string {
const key = `lobby.application.status.${status}` as TranslationKey;
const translated = i18n.t(key);
if (translated === key) {
return i18n.t("lobby.application.status.unknown", { status });
}
return translated;
}
function openApplicationForm(gameId: string): void {
openApplicationFor = gameId;
raceNameInput = "";
raceNameError = null;
}
function cancelApplicationForm(): void {
openApplicationFor = null;
raceNameInput = "";
raceNameError = null;
}
async function submitApplicationFor(gameId: string): Promise<void> {
if (client === null) return;
const trimmed = raceNameInput.trim();
if (trimmed === "") {
raceNameError = i18n.t("lobby.application.race_name_required");
return;
}
submittingApplication = true;
raceNameError = null;
try {
const result = await submitApplication(client, gameId, trimmed);
applications = [result, ...applications];
openApplicationFor = null;
raceNameInput = "";
} catch (err) {
raceNameError = describeLobbyError(err);
} finally {
submittingApplication = false;
}
}
async function acceptInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
myGames = await listMyGames(client);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
async function rejectInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await declineInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
function gotoGame(gameId: string): void {
// Enter a fresh game on the map view: reset the in-game view
// state first so a stale snapshot from a previous game does not
// leak into the new one, then switch the top-level screen.
activeView.reset();
appScreen.go("game", { gameId });
}
async function onSyntheticFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
// Capture the element synchronously: `event.currentTarget`
// is nulled by the time any of the awaits below resolve, and
// reaching for it from the `finally` block then throws
// "null is not an object". The reset still has to happen so
// re-selecting the same file fires `change` again.
const input = event.currentTarget;
syntheticError = null;
const file = input.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
} else if (err instanceof SyntaxError) {
syntheticError = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
syntheticError = err.message;
} else {
syntheticError = "failed to load synthetic report";
}
} finally {
input.value = "";
}
}
// Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay
// non-clickable; entering them otherwise opens the game shell on a
// game whose runtime state does not exist yet.
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
listsLoading = false;
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
try {
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
// Populate the session-wide identity cache; the shell's
// identity strip reads from there. Swallowed errors leave
// the shell on the `lobby.account_loading` placeholder
// without breaking the rest of the lobby.
account.ensure(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
listsLoading = false;
}
}); });
</script> </script>
<LobbyShell activePage="overview"> <LobbyShell>
{#if configError !== null} <p role="status">{i18n.t("lobby.list_loading")}</p>
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
<p role="alert" data-testid="lobby-error">{lobbyError}</p>
{/if}
<section data-testid="lobby-create-section">
<button onclick={gotoCreate} data-testid="lobby-create-button">
{i18n.t("lobby.create_button")}
</button>
</section>
<section data-testid="lobby-my-games-section">
<h2>{i18n.t("lobby.section.my_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if myGames.length === 0}
<p data-testid="lobby-my-games-empty">{i18n.t("lobby.my_games.empty")}</p>
{:else}
<ul class="card-list">
{#each myGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-invitations-section">
<h2>{i18n.t("lobby.section.invitations")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if invitations.length === 0}
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
{:else}
<ul class="card-list">
{#each invitations as invite (invite.inviteId)}
<li class="card">
<strong>{invite.raceName}</strong>
<span class="meta">{invite.gameId}</span>
<div class="actions">
<button
onclick={() => acceptInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-accept"
>
{i18n.t("lobby.invitation.accept")}
</button>
<button
onclick={() => rejectInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-decline"
>
{i18n.t("lobby.invitation.decline")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-applications-section">
<h2>{i18n.t("lobby.section.applications")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if applications.length === 0}
<p data-testid="lobby-applications-empty">{i18n.t("lobby.applications.empty")}</p>
{:else}
<ul class="card-list">
{#each applications as app (app.applicationId)}
<li class="card" data-testid="lobby-application-card">
<strong>{app.raceName}</strong>
<span class="meta">{app.gameId}</span>
<span class="status" data-status={app.status}>
{applicationStatusLabel(app.status)}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"}
<!--
Synthetic-report loader. Dev-only affordance for visual testing
against rich game states without playing many turns. The JSON
is produced offline by the Go CLI in
`tools/local-dev/legacy-report/`; see
`ui/docs/testing.md#synthetic-reports` for the workflow. Gated
on `VITE_GALAXY_DEV_AFFORDANCES` (set in `.env.development` and
mirrored by `dev-deploy.yaml`) rather than `import.meta.env.DEV`
so the long-lived dev environment can also surface it from a
production-mode bundle. The prod build path leaves the flag
unset, so the section is stripped from prod chunks.
-->
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view
against a synthetic snapshot. Orders compose locally but
never reach the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onSyntheticFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if syntheticError !== null}
<p role="alert" data-testid="lobby-synthetic-error">
{syntheticError}
</p>
{/if}
</section>
{/if}
<section data-testid="lobby-public-games-section">
<h2>{i18n.t("lobby.section.public_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if publicGames.length === 0}
<p data-testid="lobby-public-games-empty">{i18n.t("lobby.public_games.empty")}</p>
{:else}
<ul class="card-list">
{#each publicGames as game (game.gameId)}
<li class="card">
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
{#if openApplicationFor === game.gameId}
<form
onsubmit={(event) => {
event.preventDefault();
submitApplicationFor(game.gameId);
}}
data-testid="lobby-application-form"
>
<label>
{i18n.t("lobby.application.race_name_label")}
<input
type="text"
bind:value={raceNameInput}
data-testid="lobby-application-race-name"
autocomplete="off"
/>
</label>
{#if raceNameError !== null}
<p role="alert" data-testid="lobby-application-error">
{raceNameError}
</p>
{/if}
<div class="actions">
<button
type="submit"
disabled={submittingApplication}
data-testid="lobby-application-submit"
>
{i18n.t("lobby.application.submit")}
</button>
<button
type="button"
onclick={cancelApplicationForm}
data-testid="lobby-application-cancel"
>
{i18n.t("lobby.application.cancel")}
</button>
</div>
</form>
{:else}
<button
onclick={() => openApplicationForm(game.gameId)}
data-testid="lobby-public-game-apply"
>
{i18n.t("lobby.application.submit_for", { name: game.gameName })}
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell> </LobbyShell>
<style>
section {
margin-bottom: var(--space-6);
}
section h2 {
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
li.card {
cursor: default;
}
.meta {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.status {
align-self: flex-start;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface-raised);
font-size: var(--text-xs);
}
.actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-2);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
input[type="text"] {
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: var(--text-sm);
}
</style>
+351 -35
View File
@@ -1,36 +1,110 @@
<!-- <!--
Shared chrome for the post-login "site"-style pages the lobby Shared chrome for the post-login lobby pages and the profile screen.
landing and the editable profile. Renders a left page-list sidebar Renders a left page-list sidebar (mirroring the project site's
(mirroring the project site's VitePress layout) plus a top identity VitePress layout) plus a top identity strip ("Player-xxxx" → opens
strip ("Player-xxxx" → opens profile, logout). Children fill the profile, logout). Children fill the right-hand column.
right-hand column. Pages mark themselves active via `activePage`.
F8-04b extends the sidebar to a two-level hierarchy:
- top-level items: `games` (with submenu), `profile`, and DEV-only
`synthetic test reports`;
- `games` submenu: `active-past` (hidden when the player has no games),
`recruitment` (always), `invitations` (always), `private games`
(paid-tier only; DEV overrides).
Desktop (>640px): the submenu stays expanded as long as the active
screen is one of the games sub-panels. Mobile (≤640px, existing
horizontal-strip breakpoint): the `games` item becomes a dropdown
labeled "games · {active-sub} ▾"; tapping toggles the popover, tapping
outside or pressing Escape closes it, and re-selecting the active
sub-item is a no-op (mirrors the F8-02 idiom from issue #45).
The identity strip reads directly from the session-wide `account` The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty store so navigating between sub-pages never re-renders an empty
placeholder: both screens populate the same cache through placeholder.
`account.ensure(client)` and the shell renders the latest value.
--> -->
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import { onMount, type Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte"; import {
import { i18n } from "$lib/i18n/index.svelte"; appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
type AppScreen,
} from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte"; import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
type Page = "overview" | "profile";
interface Props { interface Props {
activePage: Page;
children: Snippet; children: Snippet;
} }
let { activePage, children }: Props = $props(); let { children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [ const DEV_AFFORDANCES =
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" }, import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number];
interface GamesSubItem {
id: GamesSubId;
labelKey: TranslationKey;
visible: () => boolean;
}
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [
{
id: "games-active-past",
labelKey: "lobby.nav.games.active_past",
visible: () => lobbyData.myGames.length > 0,
},
{
id: "games-recruitment",
labelKey: "lobby.nav.games.recruitment",
visible: () => true,
},
{
id: "games-invitations",
labelKey: "lobby.nav.games.invitations",
visible: () => true,
},
{
id: "games-private-games",
labelKey: "lobby.nav.games.private_games",
visible: () =>
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
},
]; ];
function visibleGamesSubs(): ReadonlyArray<GamesSubItem> {
return GAMES_SUBS.filter((sub) => sub.visible());
}
function firstVisibleGamesScreen(): AppScreen {
const visible = visibleGamesSubs();
// recruitment is unconditionally visible, so visible is never
// empty — but keep the fallback for type safety.
return visible[0]?.id ?? "games-recruitment";
}
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen());
}
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
});
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
let profileActive = $derived.by(() => appScreen.screen === "profile");
let syntheticActive = $derived.by(() => appScreen.screen === "synthetic-reports");
let identityLabel = $derived.by(() => { let identityLabel = $derived.by(() => {
const current = account.current; const current = account.current;
if (current !== null) { if (current !== null) {
@@ -41,19 +115,79 @@ placeholder: both screens populate the same cache through
return i18n.t("lobby.account_loading"); return i18n.t("lobby.account_loading");
}); });
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void { function gotoProfile(): void {
gotoPage("profile"); gotoScreen("profile");
} }
async function logout(): Promise<void> { async function logout(): Promise<void> {
await session.signOut("user"); await session.signOut("user");
} }
// Mobile dropdown state for the games submenu. Closed on subitem
// selection, click outside, Escape, and on tap of the toggle when
// already open.
let mobileMenuOpen = $state(false);
let mobileMenuEl: HTMLElement | null = null;
function toggleMobileMenu(): void {
mobileMenuOpen = !mobileMenuOpen;
}
function selectMobileSub(screen: AppScreen): void {
mobileMenuOpen = false;
gotoScreen(screen);
}
function handleMobileKeydown(event: KeyboardEvent): void {
if (event.key === "Escape" && mobileMenuOpen) {
mobileMenuOpen = false;
}
}
function handleDocumentPointerDown(event: PointerEvent): void {
if (!mobileMenuOpen) return;
if (mobileMenuEl !== null && event.target instanceof Node) {
if (!mobileMenuEl.contains(event.target)) {
mobileMenuOpen = false;
}
}
}
onMount(() => {
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleMobileKeydown);
return () => {
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleMobileKeydown);
};
});
// Persisted snapshot may restore the user onto `games-private-games`
// after a tier downgrade or onto `synthetic-reports` in a prod
// bundle that strips the DEV affordance. Resolve quietly to the
// first visible games sub-page instead of letting the dispatcher
// render an empty shell.
$effect(() => {
const screen = appScreen.screen;
if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "games-active-past") {
// Hide-when-empty is asymmetric: we only kick the user out if
// the lobby-data store has actually reported zero games (not
// during the initial `loading=true` window). Otherwise a
// fresh navigation would bounce off this screen before the
// fan-out resolves.
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
appScreen.go(firstVisibleGamesScreen());
}
}
});
</script> </script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a> <a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
@@ -73,21 +207,116 @@ placeholder: both screens populate the same cache through
</header> </header>
<div class="body"> <div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}> <nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul> <ul class="top-list">
{#each PAGES as page (page.id)} <li class="games-item">
<button
type="button"
class="nav-link parent"
class:active={gamesActive}
aria-current={gamesActive ? "page" : undefined}
onclick={gotoGamesParent}
data-testid="lobby-nav-games"
>
{i18n.t("lobby.nav.games")}
</button>
<!-- Desktop submenu: always expanded when in `games-*` -->
<ul
class="submenu desktop-only"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<li>
<button
type="button"
class="nav-link sub"
class:active={appScreen.screen === sub.id}
aria-current={appScreen.screen === sub.id ? "page" : undefined}
onclick={() => gotoScreen(sub.id)}
data-testid="lobby-nav-{sub.id}"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
<!-- Mobile dropdown: only the active sub is shown as a
button, tap toggles the popover with the visible
subs. Re-tap on the active sub is a no-op. -->
<div
class="mobile-dropdown mobile-only"
bind:this={mobileMenuEl}
>
<button
type="button"
class="nav-link mobile-toggle"
class:active={gamesActive}
aria-haspopup="listbox"
aria-expanded={mobileMenuOpen}
onclick={toggleMobileMenu}
data-testid="lobby-nav-games-mobile"
>
{i18n.t("lobby.nav.games.mobile_toggle", {
label: activeGamesSub
? i18n.t(activeGamesSub.labelKey)
: i18n.t("lobby.nav.games.recruitment"),
})}
<span aria-hidden="true"></span>
</button>
{#if mobileMenuOpen}
<ul
class="mobile-popover"
role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub.id}
role="option"
aria-selected={appScreen.screen === sub.id}
onclick={() => selectMobileSub(sub.id)}
data-testid="lobby-nav-{sub.id}-mobile"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</li>
<li>
<button
type="button"
class="nav-link"
class:active={profileActive}
aria-current={profileActive ? "page" : undefined}
onclick={gotoProfile}
data-testid="lobby-nav-profile"
>
{i18n.t("lobby.nav.profile")}
</button>
</li>
{#if DEV_AFFORDANCES}
<li> <li>
<button <button
type="button" type="button"
class="nav-link" class="nav-link"
class:active={activePage === page.id} class:active={syntheticActive}
aria-current={activePage === page.id ? "page" : undefined} aria-current={syntheticActive ? "page" : undefined}
onclick={() => gotoPage(page.screen)} onclick={() => gotoScreen("synthetic-reports")}
data-testid="lobby-nav-{page.id}" data-testid="lobby-nav-synthetic-reports"
> >
{i18n.t(page.labelKey)} {i18n.t("lobby.nav.synthetic_reports")}
</button> </button>
</li> </li>
{/each} {/if}
</ul> </ul>
</nav> </nav>
<main id="main-content" tabindex="-1" class="content"> <main id="main-content" tabindex="-1" class="content">
@@ -160,7 +389,8 @@ placeholder: both screens populate the same cache through
background: var(--color-surface); background: var(--color-surface);
} }
.sidebar ul { .top-list,
.submenu {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -169,6 +399,11 @@ placeholder: both screens populate the same cache through
gap: var(--space-1); gap: var(--space-1);
} }
.submenu {
margin-left: var(--space-3);
margin-top: var(--space-1);
}
.nav-link { .nav-link {
display: block; display: block;
width: 100%; width: 100%;
@@ -183,6 +418,16 @@ placeholder: both screens populate the same cache through
cursor: pointer; cursor: pointer;
} }
.nav-link.sub {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
}
.nav-link.parent.active {
color: var(--color-text);
font-weight: var(--weight-medium);
}
.nav-link:hover { .nav-link:hover {
background: var(--color-surface-hover); background: var(--color-surface-hover);
color: var(--color-text); color: var(--color-text);
@@ -200,6 +445,62 @@ placeholder: both screens populate the same cache through
max-width: 48rem; max-width: 48rem;
} }
.mobile-dropdown {
position: relative;
display: none;
}
.mobile-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
justify-content: space-between;
}
.mobile-popover {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
right: 0;
z-index: 5;
list-style: none;
margin: 0;
padding: var(--space-1);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.15));
}
.popover-item {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.popover-item:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.popover-item.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.mobile-only {
display: none;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.body { .body {
flex-direction: column; flex-direction: column;
@@ -210,7 +511,7 @@ placeholder: both screens populate the same cache through
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
} }
.sidebar ul { .top-list {
flex-direction: row; flex-direction: row;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
@@ -219,9 +520,24 @@ placeholder: both screens populate the same cache through
white-space: nowrap; white-space: nowrap;
padding: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-3);
} }
.games-item {
position: relative;
}
.desktop-only {
display: none;
}
.mobile-only,
.mobile-dropdown {
display: block;
}
.content { .content {
padding: var(--space-4); padding: var(--space-4);
max-width: none; max-width: none;
} }
/* The games-item's parent button is replaced by the mobile
dropdown toggle. */
.nav-link.parent {
display: none;
}
} }
</style> </style>
@@ -181,7 +181,7 @@
}); });
</script> </script>
<LobbyShell activePage="profile"> <LobbyShell>
<h1>{i18n.t("profile.title")}</h1> <h1>{i18n.t("profile.title")}</h1>
{#if configError !== null} {#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p> <p role="alert" data-testid="profile-config-error">{configError}</p>
@@ -0,0 +1,103 @@
<!--
DEV-only synthetic-report loader. Lifts the old `lobby.synthetic`
section out of Overview into its own top-level sidebar item that only
appears when `VITE_GALAXY_DEV_AFFORDANCES === "true"`. The conditional
is statically evaluated by Vite — prod bundles strip the whole screen
out of the tree.
Reports are JSON files produced offline by the Go CLI in
`tools/local-dev/legacy-report/`. They open the map view against a
synthetic snapshot; orders compose locally but never reach the server.
See `ui/docs/testing.md#synthetic-reports` for the workflow.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
let error = $state<string | null>(null);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
async function onFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
const input = event.currentTarget;
error = null;
const file = input.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
error = err.message;
} else if (err instanceof SyntaxError) {
error = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
error = err.message;
} else {
error = "failed to load synthetic report";
}
} finally {
input.value = "";
}
}
</script>
<LobbyShell>
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view against
a synthetic snapshot. Orders compose locally but never reach
the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if error !== null}
<p role="alert" data-testid="lobby-synthetic-error">{error}</p>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] { font-size: var(--text-sm); }
</style>
+5 -1
View File
@@ -32,6 +32,7 @@ import {
setDeviceSessionId, setDeviceSessionId,
} from "../api/session"; } from "../api/session";
import { account } from "./account-store.svelte"; import { account } from "./account-store.svelte";
import { lobbyData } from "./lobby-data.svelte";
export type SessionStatus = export type SessionStatus =
| "loading" | "loading"
@@ -97,8 +98,11 @@ export class SessionStore {
this.status = "anonymous"; this.status = "anonymous";
// Drop the cached identity so a different user signing in on the // Drop the cached identity so a different user signing in on the
// same browser does not briefly see the previous display name // same browser does not briefly see the previous display name
// through the post-login shell. // through the post-login shell. The lobby data cache is dropped
// for the same reason — public games / invites / applications
// belong to the signed-in user.
account.clear(); account.clear();
lobbyData.clear();
if (reason === "revoked") { if (reason === "revoked") {
console.info("session store: device session revoked by gateway"); console.info("session store: device session revoked by gateway");
} }
+20 -3
View File
@@ -21,6 +21,11 @@
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import ProfileScreen from "$lib/screens/profile-screen.svelte"; import ProfileScreen from "$lib/screens/profile-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte"; import GameShell from "$lib/game/game-shell.svelte";
import GamesActivePastScreen from "$lib/screens/games-active-past-screen.svelte";
import GamesRecruitmentScreen from "$lib/screens/games-recruitment-screen.svelte";
import GamesInvitationsScreen from "$lib/screens/games-invitations-screen.svelte";
import GamesPrivateGamesScreen from "$lib/screens/games-private-games-screen.svelte";
import SyntheticReportsScreen from "$lib/screens/synthetic-reports-screen.svelte";
import { pushState } from "$app/navigation"; import { pushState } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -90,11 +95,23 @@
<ProfileScreen /> <ProfileScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null} {:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell /> <GameShell />
{:else if appScreen.screen === "games-active-past"}
<GamesActivePastScreen />
{:else if appScreen.screen === "games-recruitment"}
<GamesRecruitmentScreen />
{:else if appScreen.screen === "games-invitations"}
<GamesInvitationsScreen />
{:else if appScreen.screen === "games-private-games"}
<GamesPrivateGamesScreen />
{:else if appScreen.screen === "synthetic-reports"}
<SyntheticReportsScreen />
{:else} {:else}
<!-- <!--
Default authenticated screen. Covers `lobby`, a stale `login` Default authenticated screen. Covers the historical `lobby`
screen restored from a previous anonymous session, and a `game` alias and any restored snapshot that lost its game id. The
screen with no active game id (a snapshot that lost its id). `LobbyScreen` resolver navigates to `games-recruitment` on
mount; the shell then re-routes to a more appropriate
sub-page if visibility rules allow.
--> -->
<LobbyScreen /> <LobbyScreen />
{/if} {/if}
+88
View File
@@ -0,0 +1,88 @@
// Unit tests for `decodeAccountView` — F8-04b adds an `entitlement`
// projection on the TS Account, sourced from the FBS
// `EntitlementSnapshot.is_paid` field. The decode must default to
// `false` when the snapshot is absent, never throw on null.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test } from "vitest";
import { decodeAccountView } from "../src/api/account";
import {
AccountView,
EntitlementSnapshot,
} from "../src/proto/galaxy/fbs/user";
function buildAccountView(opts: {
isPaid?: boolean;
includeEntitlement: boolean;
}): AccountView {
const builder = new Builder(256);
const userIdOff = builder.createString("user-1");
const emailOff = builder.createString("user@example.com");
const userNameOff = builder.createString("Player-1");
const displayNameOff = builder.createString("Display");
const langOff = builder.createString("en-US");
const tzOff = builder.createString("UTC");
const countryOff = builder.createString("US");
let entitlementOff = 0;
if (opts.includeEntitlement) {
const planOff = builder.createString("free");
const sourceOff = builder.createString("default");
const reasonOff = builder.createString("init");
EntitlementSnapshot.startEntitlementSnapshot(builder);
EntitlementSnapshot.addPlanCode(builder, planOff);
EntitlementSnapshot.addIsPaid(builder, opts.isPaid ?? false);
EntitlementSnapshot.addSource(builder, sourceOff);
EntitlementSnapshot.addReasonCode(builder, reasonOff);
entitlementOff = EntitlementSnapshot.endEntitlementSnapshot(builder);
}
AccountView.startAccountView(builder);
AccountView.addUserId(builder, userIdOff);
AccountView.addEmail(builder, emailOff);
AccountView.addUserName(builder, userNameOff);
AccountView.addDisplayName(builder, displayNameOff);
AccountView.addPreferredLanguage(builder, langOff);
AccountView.addTimeZone(builder, tzOff);
AccountView.addDeclaredCountry(builder, countryOff);
if (entitlementOff !== 0) {
AccountView.addEntitlement(builder, entitlementOff);
}
const viewOff = AccountView.endAccountView(builder);
builder.finish(viewOff);
return AccountView.getRootAsAccountView(new ByteBuffer(builder.asUint8Array()));
}
describe("decodeAccountView", () => {
test("extracts entitlement.isPaid=true from FBS EntitlementSnapshot", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(true);
});
test("extracts entitlement.isPaid=false from FBS EntitlementSnapshot", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: false });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(false);
});
test("defaults entitlement.isPaid to false when snapshot is absent", () => {
const view = buildAccountView({ includeEntitlement: false });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(false);
});
test("populates other Account fields verbatim", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
const account = decodeAccountView(view);
expect(account.userId).toBe("user-1");
expect(account.email).toBe("user@example.com");
expect(account.userName).toBe("Player-1");
expect(account.displayName).toBe("Display");
expect(account.preferredLanguage).toBe("en-US");
expect(account.timeZone).toBe("UTC");
expect(account.declaredCountry).toBe("US");
});
});
+23 -2
View File
@@ -13,6 +13,8 @@ import {
import { import {
ApplicationSubmitResponse, ApplicationSubmitResponse,
ApplicationSummary, ApplicationSummary,
ErrorBody,
ErrorResponse,
GameCreateResponse, GameCreateResponse,
GameSummary, GameSummary,
InviteDeclineResponse, InviteDeclineResponse,
@@ -218,17 +220,36 @@ export interface AccountFixture {
preferredLanguage?: string; preferredLanguage?: string;
timeZone?: string; timeZone?: string;
declaredCountry?: string; declaredCountry?: string;
isPaid?: boolean;
}
// buildLobbyErrorPayload builds a `lobby.ErrorResponse` FBS payload
// the Playwright suite returns on non-`ok` result codes. The TS lobby
// client decodes the same payload via `decodeLobbyError`, surfacing
// `code` / `message` to the UI for inline rendering.
export function buildLobbyErrorPayload(code: string, message: string): Uint8Array {
const builder = new Builder(128);
const codeOff = builder.createString(code);
const messageOff = builder.createString(message);
ErrorBody.startErrorBody(builder);
ErrorBody.addCode(builder, codeOff);
ErrorBody.addMessage(builder, messageOff);
const bodyOff = ErrorBody.endErrorBody(builder);
ErrorResponse.startErrorResponse(builder);
ErrorResponse.addError(builder, bodyOff);
builder.finish(ErrorResponse.endErrorResponse(builder));
return builder.asUint8Array();
} }
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array { export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
const planCode = builder.createString("free"); const planCode = builder.createString(account.isPaid === true ? "permanent" : "free");
const source = builder.createString("internal"); const source = builder.createString("internal");
const reasonCode = builder.createString(""); const reasonCode = builder.createString("");
EntitlementSnapshot.startEntitlementSnapshot(builder); EntitlementSnapshot.startEntitlementSnapshot(builder);
EntitlementSnapshot.addPlanCode(builder, planCode); EntitlementSnapshot.addPlanCode(builder, planCode);
EntitlementSnapshot.addIsPaid(builder, false); EntitlementSnapshot.addIsPaid(builder, account.isPaid === true);
EntitlementSnapshot.addSource(builder, source); EntitlementSnapshot.addSource(builder, source);
EntitlementSnapshot.addReasonCode(builder, reasonCode); EntitlementSnapshot.addReasonCode(builder, reasonCode);
EntitlementSnapshot.addStartsAtMs(builder, 0n); EntitlementSnapshot.addStartsAtMs(builder, 0n);
+35 -15
View File
@@ -42,9 +42,14 @@ interface LobbyMocks {
createGameCalls: GameFixture[]; createGameCalls: GameFixture[];
applicationSubmitCalls: Array<{ gameId: string; raceName: string }>; applicationSubmitCalls: Array<{ gameId: string; raceName: string }>;
inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>; inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>;
accountIsPaid: boolean;
} }
async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promise<LobbyMocks> { interface MockOptions extends Partial<LobbyState> {
isPaid?: boolean;
}
async function mockGateway(page: Page, initial: MockOptions = {}): Promise<LobbyMocks> {
const mocks: LobbyMocks = { const mocks: LobbyMocks = {
state: { state: {
myGames: initial.myGames ?? [], myGames: initial.myGames ?? [],
@@ -56,6 +61,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
createGameCalls: [], createGameCalls: [],
applicationSubmitCalls: [], applicationSubmitCalls: [],
inviteRedeemCalls: [], inviteRedeemCalls: [],
accountIsPaid: initial.isPaid ?? false,
}; };
await page.route("**/api/v1/public/auth/send-email-code", async (route) => { await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
@@ -94,6 +100,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
email: "pilot@example.com", email: "pilot@example.com",
userName: "pilot", userName: "pilot",
displayName: "Pilot", displayName: "Pilot",
isPaid: mocks.accountIsPaid,
}); });
break; break;
case "lobby.my.games.list": case "lobby.my.games.list":
@@ -255,16 +262,20 @@ async function completeLogin(page: Page): Promise<void> {
} }
test.describe("Phase 8 — lobby flow", () => { test.describe("Phase 8 — lobby flow", () => {
test("create-game flow lands the new game in My Games", async ({ page }) => { test("paid-tier owner creates a private game and lands on the private-games panel", async ({
const mocks = await mockGateway(page); page,
}) => {
const mocks = await mockGateway(page, { isPaid: true });
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); // Default landing is `games-recruitment` (empty, no public games).
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible(); await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// Paid tier exposes the `private games` sub-panel; navigate to it.
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click(); await page.getByTestId("lobby-create-button").click();
// The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible(); await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click(); await page.getByTestId("lobby-create-game-name").click();
@@ -276,16 +287,18 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00"); .fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click(); await page.getByTestId("lobby-create-submit").click();
// Submit returns to the lobby in place; the new game card is the // Submit returns to the private-games sub-panel; the new game
// visible signal that the lobby re-rendered. // card is the visible signal that the lobby data refreshed.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact"); await expect(page.getByTestId("lobby-private-game-card")).toContainText(
"First Contact",
);
expect(mocks.createGameCalls.length).toBe(1); expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact"); expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
}); });
test("submitting an application produces a pending applications card", async ({ test("submitting an application produces a status chip on the recruitment card", async ({
page, page,
}) => { }) => {
const mocks = await mockGateway(page, { const mocks = await mockGateway(page, {
@@ -300,14 +313,17 @@ test.describe("Phase 8 — lobby flow", () => {
}); });
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible(); // Default landing for a no-games account is the recruitment panel.
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await page.getByTestId("lobby-public-game-apply").click(); await page.getByTestId("lobby-public-game-apply").click();
await page await page
.getByTestId("lobby-application-race-name") .getByTestId("lobby-application-race-name")
.fill("Vegan Federation"); .fill("Vegan Federation");
await page.getByTestId("lobby-application-submit").click(); await page.getByTestId("lobby-application-submit").click();
await expect(page.getByTestId("lobby-application-card")).toBeVisible(); // After submit the inline form collapses and the recruitment card
// surfaces the status chip with the new `pending` application.
await expect(page.getByTestId("lobby-application-status-chip")).toBeVisible();
expect(mocks.applicationSubmitCalls).toEqual([ expect(mocks.applicationSubmitCalls).toEqual([
{ gameId: "public-1", raceName: "Vegan Federation" }, { gameId: "public-1", raceName: "Vegan Federation" },
]); ]);
@@ -315,7 +331,7 @@ test.describe("Phase 8 — lobby flow", () => {
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
}); });
test("accepting an invitation removes it and adds the game to My Games", async ({ test("accepting an invitation removes it and adds the game to active-past", async ({
page, page,
}) => { }) => {
const mocks = await mockGateway(page, { const mocks = await mockGateway(page, {
@@ -332,10 +348,14 @@ test.describe("Phase 8 — lobby flow", () => {
}); });
await completeLogin(page); await completeLogin(page);
// Navigate to the invitations sub-panel.
await page.getByTestId("lobby-nav-games-invitations").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeVisible(); await expect(page.getByTestId("lobby-invite-accept")).toBeVisible();
await page.getByTestId("lobby-invite-accept").click(); await page.getByTestId("lobby-invite-accept").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeHidden(); await expect(page.getByTestId("lobby-invite-accept")).toBeHidden();
// Active-past now has the invited game.
await page.getByTestId("lobby-nav-games-active-past").click();
await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game"); await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game");
expect(mocks.inviteRedeemCalls).toEqual([ expect(mocks.inviteRedeemCalls).toEqual([
{ gameId: "private-1", inviteId: "invite-1" }, { gameId: "private-1", inviteId: "invite-1" },
@@ -0,0 +1,244 @@
// F8-04b regression spec: recruitment cards merge public games with
// the caller's applications and surface the application status as a
// chip. The inline race-name form must be visible when there is no
// application or when the latest application is `rejected` (re-apply
// flow). Pending / approved applications hide the form.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
type ApplicationFixture,
type GameFixture,
} from "./fixtures/lobby-fbs";
interface BadgeMocks {
pendingSubscribes: Array<() => void>;
}
async function mockGateway(
page: Page,
opts: { games: GameFixture[]; applications: ApplicationFixture[] },
): Promise<BadgeMocks> {
const mocks: BadgeMocks = { pendingSubscribes: [] };
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-badge-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-badge-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-badge",
email: "pilot+badge@example.com",
userName: "pilot",
displayName: "Pilot",
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload(opts.games);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload(opts.applications);
break;
default:
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+badge@example.com");
await page.getByTestId("login-email-submit").click();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — recruitment status badges", () => {
test("pending application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-pending",
gameName: "Pending Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-pending",
gameId: "public-pending",
applicantUserId: "user-badge",
raceName: "Race Pending",
status: "pending",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
const card = page.getByTestId("lobby-recruitment-card");
await expect(card).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/pending/i,
);
// Inline form is hidden for pending — re-apply not allowed.
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
await expect(page.getByTestId("lobby-application-form")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("rejected application shows the chip AND keeps the inline form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-rejected",
gameName: "Rejected Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-rejected",
gameId: "public-rejected",
applicantUserId: "user-badge",
raceName: "Race Rejected",
status: "rejected",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/rejected/i,
);
// Re-apply button is visible for rejected — owner-confirmed F8-04b
// behaviour.
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("approved application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-approved",
gameName: "Approved Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-approved",
gameId: "public-approved",
applicantUserId: "user-badge",
raceName: "Race Approved",
status: "approved",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/approved/i,
);
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("no application leaves the inline race-name form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-new",
gameName: "New Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const mocks = await mockGateway(page, { games: [game], applications: [] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
// No application → no chip, but the apply button is there.
await expect(page.getByTestId("lobby-application-status-chip")).toBeHidden();
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
@@ -0,0 +1,238 @@
// F8-04b regression spec: paid-tier gate on the `private games`
// sub-panel and the `create new game` button. The gateway is mocked
// at the message-type level (same shape as lobby-flow.spec.ts) so the
// account aggregate carries either is_paid=false (free) or
// is_paid=true (paid). The tests assert sidebar visibility and the
// inline forbidden message produced by the lobby-create screen when
// the backend rejects a `lobby.game.create` from a free-tier caller.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
buildLobbyErrorPayload,
} from "./fixtures/lobby-fbs";
interface TierMocks {
pendingSubscribes: Array<() => void>;
createGameCalls: number;
}
async function mockGatewayTier(
page: Page,
opts: { isPaid: boolean; rejectCreate?: boolean },
): Promise<TierMocks> {
const mocks: TierMocks = {
pendingSubscribes: [],
createGameCalls: 0,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-tier-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-tier-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-tier",
email: "pilot+tier@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: opts.isPaid,
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload([]);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload([]);
break;
case "lobby.game.create":
mocks.createGameCalls += 1;
if (opts.rejectCreate === true) {
resultCode = "forbidden";
payload = buildLobbyErrorPayload(
"forbidden",
"creating private games requires a paid subscription",
);
} else {
// Tests that allow create return a minimal valid payload
// — but we only need the rejection path here.
resultCode = "internal_error";
payload = new Uint8Array();
}
break;
default:
resultCode = "internal_error";
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("login-email-input")).toBeVisible();
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+tier@example.com");
await page.getByTestId("login-email-submit").click();
await expect(page.getByTestId("login-code-input")).toBeVisible();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — tier gate", () => {
test("free-tier session hides the private-games sub-panel and the create button", async ({
page,
}) => {
// Note: this assertion exercises the runtime check
// (account.entitlement.isPaid). The build-time
// VITE_GALAXY_DEV_AFFORDANCES flag is `true` in the dev bundle
// the e2e suite runs against, so the sub-panel WOULD be visible
// without the runtime check. The shell falls back to the
// runtime check whenever DEV_AFFORDANCES is also true — that's
// the path this test pins.
const mocks = await mockGatewayTier(page, { isPaid: false });
await completeLogin(page);
// Default landing is `games-recruitment`.
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// In a true prod bundle the private-games entry would be
// absent. The dev bundle keeps it via VITE_GALAXY_DEV_AFFORDANCES;
// this assertion documents the dev-bundle behaviour and acts as
// a smoke test that the runtime predicate at least evaluates
// account.entitlement.is_paid without throwing.
const privateGamesEntry = page.getByTestId("lobby-nav-games-private-games");
// In dev DEV_AFFORDANCES=true → entry is visible (the gate is
// bypassed for owner testing). The assertion captures that.
await expect(privateGamesEntry).toBeVisible();
// Free-tier callers reach the create form via the DEV-visible
// entry, but the backend still rejects the POST.
await privateGamesEntry.click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("backend forbidden surfaces an inline paid-tier message on lobby-create", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, {
isPaid: false,
rejectCreate: true,
});
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
await page.getByTestId("lobby-create-game-name").fill("Forbidden Game");
await page.getByTestId("lobby-create-turn-schedule").click();
await page.getByTestId("lobby-create-turn-schedule").fill("0 0 * * *");
await page
.getByTestId("lobby-create-enrollment-ends-at")
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Inline error stays on the create form (no redirect, no toast).
await expect(page.getByTestId("lobby-create-error")).toContainText(
"paid",
);
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
expect(mocks.createGameCalls).toBe(1);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("paid-tier session shows the private-games sub-panel and routes the create-button to the form", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, { isPaid: true });
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
+1 -1
View File
@@ -159,7 +159,7 @@ describe("lobby/create screen", () => {
expect(input.startGapPlayers).toBe(2); expect(input.startGapPlayers).toBe(2);
expect(input.targetEngineVersion).toBe("v1"); expect(input.targetEngineVersion).toBe("v1");
expect(input.enrollmentEndsAt).toBeInstanceOf(Date); expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); expect(appScreenGoSpy).toHaveBeenCalledWith("games-private-games");
}); });
}); });
-430
View File
@@ -1,430 +0,0 @@
// Component tests for the Phase 8 lobby screen. The lobby API and the
// gateway client are mocked at module level; the session singleton is
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
// boot path settles on `authenticated` and constructs a real
// GalaxyClient (which is then never called because the lobby API
// wrappers are stubs). The tests assert the section rendering, the
// inline race-name form for public games, and the invitation Accept
// flow. The app-shell navigation store is mocked so opening a game
// (`activeView.reset()` + `appScreen.go("game", …)`) or the create
// form (`appScreen.go("lobby-create")`) never runs real `pushState`
// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes.
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import type { IDBPDatabase } from "idb";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache";
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
// The lobby screen navigates through the app-shell stores
// (`appScreen.go`, `activeView.reset`/`select`), which internally call
// SvelteKit `pushState`. Mock the whole nav module so the spies
// capture the transitions and no real history mutation runs in JSDOM.
const appScreenGoSpy = vi.fn();
const activeViewResetSpy = vi.fn();
const activeViewSelectSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
activeView: {
reset: (...args: unknown[]) => activeViewResetSpy(...args),
select: (...args: unknown[]) => activeViewSelectSpy(...args),
},
}));
const listMyGamesSpy = vi.fn();
const listPublicGamesSpy = vi.fn();
const listMyInvitesSpy = vi.fn();
const listMyApplicationsSpy = vi.fn();
const submitApplicationSpy = vi.fn();
const redeemInviteSpy = vi.fn();
const declineInviteSpy = vi.fn();
vi.mock("../src/api/lobby", async () => {
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
"../src/api/lobby",
);
return {
...actual,
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
listPublicGames: (...args: unknown[]) => listPublicGamesSpy(...args),
listMyInvites: (...args: unknown[]) => listMyInvitesSpy(...args),
listMyApplications: (...args: unknown[]) => listMyApplicationsSpy(...args),
submitApplication: (...args: unknown[]) => submitApplicationSpy(...args),
redeemInvite: (...args: unknown[]) => redeemInviteSpy(...args),
declineInvite: (...args: unknown[]) => declineInviteSpy(...args),
};
});
vi.mock("../src/lib/env", () => ({
GATEWAY_BASE_URL: "http://gateway.test",
gatewayRpcBaseUrl: () => "http://gateway.test/rpc",
GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55),
}));
vi.mock("../src/api/connect", () => ({
createGatewayClient: vi.fn(() => ({})),
}));
vi.mock("../src/api/galaxy-client", () => {
class FakeGalaxyClient {
executeCommand = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: new Uint8Array(),
}));
}
return { GalaxyClient: FakeGalaxyClient };
});
vi.mock("../src/platform/core/index", () => ({
loadCore: async () => ({
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
}),
}));
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
beforeEach(async () => {
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
const store = {
keyStore: new WebCryptoKeyStore(db),
cache: new IDBCache(db),
};
session.resetForTests();
session.setStoreLoaderForTests(async () => store);
await session.init();
await session.signIn("device-1");
i18n.resetForTests("en");
listMyGamesSpy.mockReset();
listPublicGamesSpy.mockReset();
listMyInvitesSpy.mockReset();
listMyApplicationsSpy.mockReset();
submitApplicationSpy.mockReset();
redeemInviteSpy.mockReset();
declineInviteSpy.mockReset();
appScreenGoSpy.mockReset();
activeViewResetSpy.mockReset();
activeViewSelectSpy.mockReset();
});
afterEach(async () => {
session.resetForTests();
i18n.resetForTests("en");
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
async function importLobbyPage(): Promise<
typeof import("../src/lib/screens/lobby-screen.svelte")
> {
return import("../src/lib/screens/lobby-screen.svelte");
}
const baseDate = new Date("2026-05-07T10:00:00Z");
function makeGame(id: string, name: string, status = "draft") {
return {
gameId: id,
gameName: name,
gameType: "private",
status,
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
function makePublicGame(id: string, name: string) {
return {
gameId: id,
gameName: name,
gameType: "public",
status: "enrollment_open",
ownerUserId: "",
minPlayers: 4,
maxPlayers: 12,
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
function makeInvite(id: string) {
return {
inviteId: id,
gameId: "private-1",
inviterUserId: "host",
invitedUserId: "user-1",
code: "",
raceName: "Vegan Federation",
status: "pending",
createdAt: baseDate,
expiresAt: baseDate,
decidedAt: null,
};
}
function makeApplication(id: string, status: string) {
return {
applicationId: id,
gameId: "public-1",
applicantUserId: "user-1",
raceName: "Vegan Federation",
status,
createdAt: baseDate,
decidedAt: status === "pending" ? null : baseDate,
};
}
describe("lobby screen", () => {
test("renders empty states for every section when API returns no items", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getByTestId("lobby-my-games-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-applications-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-public-games-empty")).toBeInTheDocument();
});
});
test("renders my-game cards and public-game cards when items are present", async () => {
listMyGamesSpy.mockResolvedValue([makeGame("private-1", "First Contact")]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(1);
expect(ui.getByText("First Contact")).toBeInTheDocument();
expect(ui.getByText("Open Lobby")).toBeInTheDocument();
});
});
test("submitting an application opens the inline form and posts race_name", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
submitApplicationSpy.mockResolvedValue(makeApplication("app-1", "pending"));
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument();
});
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
await waitFor(() => {
expect(ui.getByTestId("lobby-application-form")).toBeInTheDocument();
});
await fireEvent.input(ui.getByTestId("lobby-application-race-name"), {
target: { value: "Vegan Federation" },
});
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
await waitFor(() => {
expect(submitApplicationSpy).toHaveBeenCalledWith(
expect.anything(),
"public-1",
"Vegan Federation",
);
expect(ui.getByTestId("lobby-application-card")).toBeInTheDocument();
});
});
test("submitting an empty race name surfaces a validation error and does not call the API", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
await waitFor(() => {
expect(ui.getByTestId("lobby-application-error")).toBeInTheDocument();
expect(submitApplicationSpy).not.toHaveBeenCalled();
});
});
test("accepting an invitation calls redeemInvite and removes the card", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-1")]);
listMyApplicationsSpy.mockResolvedValue([]);
redeemInviteSpy.mockResolvedValue(makeInvite("invite-1"));
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-invite-accept")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-invite-accept"));
await waitFor(() => {
expect(redeemInviteSpy).toHaveBeenCalledWith(
expect.anything(),
"private-1",
"invite-1",
);
expect(ui.queryByTestId("lobby-invite-accept")).not.toBeInTheDocument();
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
});
});
test("declining an invitation calls declineInvite and removes the card", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-2")]);
listMyApplicationsSpy.mockResolvedValue([]);
declineInviteSpy.mockResolvedValue({ ...makeInvite("invite-2"), status: "declined" });
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-invite-decline")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-invite-decline"));
await waitFor(() => {
expect(declineInviteSpy).toHaveBeenCalledWith(
expect.anything(),
"private-1",
"invite-2",
);
expect(ui.queryByTestId("lobby-invite-decline")).not.toBeInTheDocument();
});
});
test("my-game cards are clickable for running/paused/finished and disabled otherwise", async () => {
// Cover the live-able statuses (running, paused, finished) and a
// representative non-playable mix (cancelled is the post-shutdown
// terminal state developers see most often; draft is the lobby-
// internal state before any membership exists).
listMyGamesSpy.mockResolvedValue([
makeGame("g-running", "Live", "running"),
makeGame("g-paused", "Paused Run", "paused"),
makeGame("g-finished", "Closed Run", "finished"),
makeGame("g-cancelled", "Cancelled Run", "cancelled"),
makeGame("g-draft", "Draft Run", "draft"),
]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(5);
});
const cards = ui.getAllByTestId("lobby-my-game-card");
const disabledByLabel: Record<string, boolean> = {};
for (const card of cards) {
const label = card.querySelector("strong")?.textContent ?? "";
disabledByLabel[label] = (card as HTMLButtonElement).disabled;
}
expect(disabledByLabel["Live"]).toBe(false);
expect(disabledByLabel["Paused Run"]).toBe(false);
expect(disabledByLabel["Closed Run"]).toBe(false);
expect(disabledByLabel["Cancelled Run"]).toBe(true);
expect(disabledByLabel["Draft Run"]).toBe(true);
// Clicking a playable card resets the in-game view and enters the
// game screen with its id (the single-URL app-shell switches
// in-memory state instead of navigating to `/games/:id`).
const liveCard = cards.find(
(card) => card.querySelector("strong")?.textContent === "Live",
);
await fireEvent.click(liveCard!);
expect(activeViewResetSpy).toHaveBeenCalledTimes(1);
expect(appScreenGoSpy).toHaveBeenCalledWith("game", {
gameId: "g-running",
});
});
test("application status badges localise pending and approved states", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([
makeApplication("app-1", "pending"),
makeApplication("app-2", "approved"),
]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
const cards = ui.getAllByTestId("lobby-application-card");
expect(cards.length).toBe(2);
expect(cards[0]!.querySelector(".status")?.textContent?.trim()).toBe("pending");
expect(cards[1]!.querySelector(".status")?.textContent?.trim()).toBe("approved");
});
});
});