feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Reshape the lobby UI from a single Overview into a two-level sidebar (games · profile · DEV synthetic-reports) with four games sub-panels (active-past · recruitment · invitations · private-games). Move the `create new game` button into the private-games panel, merge the applications section into recruitment cards as status chips, and add DEV-only synthetic-report loader as a top-level screen. Add a paid-tier gate at backend `lobby.game.create`: free callers get `403 forbidden` before the lobby service is invoked. The UI hides the private-games sub-panel + create button on free tier (DEV affordances flag overrides). Update every integration test that creates a game to use a new `testenv.PromoteToPaid` helper; add a new `TestLobbyFlow_FreeUserCreateGameForbidden`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "операция запрещена",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user