Merge pull request 'feat(lobby): cap simultaneous quick games at 10' (#76) from feature/quick-game-limit into development
This commit was merged in pull request #76.
This commit is contained in:
@@ -33,6 +33,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
||||||
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
||||||
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
||||||
|
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
@@ -520,3 +521,27 @@ Then Stage 18.
|
|||||||
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
|
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
|
||||||
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
|
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
|
||||||
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
|
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
|
||||||
|
|
||||||
|
- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at
|
||||||
|
most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice
|
||||||
|
"Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes.
|
||||||
|
- **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including
|
||||||
|
AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game
|
||||||
|
creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting**
|
||||||
|
an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean
|
||||||
|
**`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count,
|
||||||
|
and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame
|
||||||
|
defaults the button **enabled** (the backend gate is the authority).
|
||||||
|
- **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no
|
||||||
|
`game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list);
|
||||||
|
`Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`;
|
||||||
|
`gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through
|
||||||
|
the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted
|
||||||
|
`.limit` notice); i18n `lobby.limitReached` (en authoritative + ru).
|
||||||
|
- **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could
|
||||||
|
momentarily exceed by 1–2 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
|
||||||
|
a disproportionate diff across the two create paths.
|
||||||
|
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
|
||||||
|
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
|
||||||
|
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
|
||||||
|
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
|
||||||
|
|||||||
+6
-1
@@ -33,7 +33,12 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
|
|||||||
another player already waits for the same variant and per-turn word rule, seats the
|
another player already waits for the same variant and per-turn word rule, seats the
|
||||||
caller into that open game and starts it — and
|
caller into that open game and starts it — and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
||||||
|
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
||||||
|
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
||||||
|
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
||||||
|
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
||||||
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
including obfuscated forms) and stored with the sender's IP. `internal/account`
|
including obfuscated forms) and stored with the sender's IP. `internal/account`
|
||||||
|
|||||||
@@ -1178,6 +1178,14 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
|||||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
||||||
|
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
||||||
|
// counts active and still-open quick games (including honest-AI ones) and excludes
|
||||||
|
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
|
||||||
|
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||||
|
return svc.store.CountActiveQuickGames(ctx, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||||
|
|||||||
@@ -450,6 +450,28 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
|
||||||
|
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
|
||||||
|
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
|
||||||
|
// excludes friend games (those linked to a game_invitations row) and finished games.
|
||||||
|
// A hidden game still occupies a slot, so this is a dedicated count rather than a
|
||||||
|
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
|
||||||
|
// own seat counts each game once (an open game's empty opponent seat has no account).
|
||||||
|
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||||
|
// The status literals are game.StatusActive / game.StatusOpen, matching the
|
||||||
|
// games.status CHECK in the baseline migration.
|
||||||
|
const q = `
|
||||||
|
SELECT COUNT(*) FROM backend.games g
|
||||||
|
JOIN backend.game_players gp ON gp.game_id = g.game_id
|
||||||
|
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
|
||||||
|
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("game: count active quick games: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||||
// game is finished and the account is a player.
|
// game is finished and the account is a player.
|
||||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ var (
|
|||||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
||||||
|
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||||
|
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||||
|
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllowedTurnTimeouts is the set of per-game move clocks a game may be created
|
// AllowedTurnTimeouts is the set of per-game move clocks a game may be created
|
||||||
@@ -87,6 +90,15 @@ const DefaultTurnTimeout = 24 * time.Hour
|
|||||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
|
||||||
|
// auto-match and honest-AI), counting both in-progress (StatusActive) and
|
||||||
|
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
|
||||||
|
// invitation are not counted. At the cap the backend refuses to create a new game
|
||||||
|
// of any kind — quick or by invitation — and the lobby disables "New Game";
|
||||||
|
// accepting an incoming invitation is always allowed. See
|
||||||
|
// Store.CountActiveQuickGames.
|
||||||
|
const MaxActiveQuickGames = 10
|
||||||
|
|
||||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||||
// UI shows 🤖 from the game's vs_ai flag).
|
// UI shows 🤖 from the game's vs_ai flag).
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
|
||||||
|
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
|
||||||
|
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
|
||||||
|
|
||||||
|
// TestCountActiveQuickGames checks the count includes active and open quick games (the
|
||||||
|
// honest-AI ones among them) and excludes finished games, friend games (created by
|
||||||
|
// invitation) and games the account is not seated in.
|
||||||
|
func TestCountActiveQuickGames(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
clearOpenGames(t)
|
||||||
|
games := newGameService()
|
||||||
|
human := provisionAccount(t)
|
||||||
|
opp := provisionAccount(t)
|
||||||
|
|
||||||
|
if n := mustCount(t, games, human); n != 0 {
|
||||||
|
t.Fatalf("fresh account count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open (awaiting-opponent) quick game counts.
|
||||||
|
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||||
|
}, time.Now().Add(time.Minute)); err != nil {
|
||||||
|
t.Fatalf("open quick game: %v", err)
|
||||||
|
}
|
||||||
|
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||||
|
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
|
||||||
|
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
|
||||||
|
|
||||||
|
// A finished quick game does NOT count.
|
||||||
|
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
|
||||||
|
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
|
||||||
|
t.Fatalf("finish game: %v", err)
|
||||||
|
}
|
||||||
|
// A game the human is not seated in does NOT count.
|
||||||
|
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
|
||||||
|
// A friend game (created by invitation) does NOT count, even though it is active.
|
||||||
|
startFriendGame(t, human)
|
||||||
|
|
||||||
|
if n := mustCount(t, games, human); n != 3 {
|
||||||
|
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
|
||||||
|
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
|
||||||
|
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
|
||||||
|
// incoming invitation is still allowed.
|
||||||
|
func TestGameLimitGate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
games := newGameService()
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zaptest.NewLogger(t),
|
||||||
|
DB: testDB,
|
||||||
|
Accounts: account.NewStore(testDB),
|
||||||
|
Games: games,
|
||||||
|
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
||||||
|
Invitations: newInvitationService(),
|
||||||
|
})
|
||||||
|
|
||||||
|
human := provisionAccount(t)
|
||||||
|
opp := provisionAccount(t)
|
||||||
|
|
||||||
|
// Under the cap: the lobby reports the player is not limited.
|
||||||
|
if gamesListAtLimit(t, srv, human) {
|
||||||
|
t.Fatal("a fresh account must be under the game limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reach the cap with active quick games seating the human (no invitation row → quick).
|
||||||
|
for i := 0; i < game.MaxActiveQuickGames; i++ {
|
||||||
|
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// At the cap: the lobby flags it and both create paths are refused with the stable code.
|
||||||
|
if !gamesListAtLimit(t, srv, human) {
|
||||||
|
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
||||||
|
}
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||||
|
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||||
|
}
|
||||||
|
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||||
|
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
|
||||||
|
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
|
||||||
|
inviter := provisionAccount(t)
|
||||||
|
inv := newInvitationService()
|
||||||
|
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create invitation: %v", err)
|
||||||
|
}
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCount returns the account's active-quick-game count, failing on error.
|
||||||
|
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
|
||||||
|
t.Helper()
|
||||||
|
n, err := games.CountActiveQuickGames(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count active quick games: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
|
||||||
|
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
|
||||||
|
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
|
||||||
|
if vsAI {
|
||||||
|
p.VsAI = true
|
||||||
|
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
|
||||||
|
}
|
||||||
|
g, err := games.Create(context.Background(), p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
|
||||||
|
}
|
||||||
|
return g.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
|
||||||
|
// accept it, so the (active) friend game exists with its game_invitations row.
|
||||||
|
func startFriendGame(t *testing.T, invitee uuid.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
inv := newInvitationService()
|
||||||
|
inviter := provisionAccount(t)
|
||||||
|
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create invitation: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
||||||
|
func gamesListAtLimit(t *testing.T, srv *server.Server, id uuid.UUID) bool {
|
||||||
|
t.Helper()
|
||||||
|
rec := userGet(t, srv, "/api/v1/user/games", id)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("games.list status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
AtGameLimit bool `json:"at_game_limit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode games.list: %v", err)
|
||||||
|
}
|
||||||
|
return body.AtGameLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPost issues an authenticated JSON POST (X-User-ID) against the assembled server.
|
||||||
|
func userPost(t *testing.T, srv *server.Server, path string, id uuid.UUID, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("X-User-ID", id.String())
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ func TestStatusForError(t *testing.T) {
|
|||||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||||
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
||||||
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
|
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
|
||||||
|
"game limit": {game.ErrGameLimitReached, http.StatusConflict, "game_limit_reached"},
|
||||||
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "no_opponent_yet"
|
return http.StatusConflict, "no_opponent_yet"
|
||||||
case errors.Is(err, game.ErrGameActive):
|
case errors.Is(err, game.ErrGameActive):
|
||||||
return http.StatusConflict, "game_active"
|
return http.StatusConflict, "game_active"
|
||||||
|
case errors.Is(err, game.ErrGameLimitReached):
|
||||||
|
return http.StatusConflict, "game_limit_reached"
|
||||||
case errors.Is(err, account.ErrInvalidProfile):
|
case errors.Is(err, account.ErrInvalidProfile):
|
||||||
return http.StatusBadRequest, "invalid_profile"
|
return http.StatusBadRequest, "invalid_profile"
|
||||||
case errors.Is(err, account.ErrAlreadyConfirmed):
|
case errors.Is(err, account.ErrAlreadyConfirmed):
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ type historyDTO struct {
|
|||||||
Moves []moveRecordDTO `json:"moves"`
|
Moves []moveRecordDTO `json:"moves"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// gameListDTO is the caller's games (active and finished) for the lobby.
|
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
||||||
|
// reports whether the caller has reached the simultaneous quick-game cap
|
||||||
|
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
|
||||||
|
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
|
||||||
|
// instead of a separate request.
|
||||||
type gameListDTO struct {
|
type gameListDTO struct {
|
||||||
Games []gameDTO `json:"games"`
|
Games []gameDTO `json:"games"`
|
||||||
|
AtGameLimit bool `json:"at_game_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatListDTO is a game's chat history.
|
// chatListDTO is a game's chat history.
|
||||||
@@ -388,6 +393,34 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atGameLimit reports whether uid already holds the maximum number of simultaneous
|
||||||
|
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
|
||||||
|
// and the new-game gate; friend games created by invitation are not counted.
|
||||||
|
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
|
||||||
|
n, err := s.games.CountActiveQuickGames(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n >= game.MaxActiveQuickGames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
|
||||||
|
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
|
||||||
|
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
|
||||||
|
// incoming invitation is deliberately exempt.
|
||||||
|
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
|
||||||
|
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if atLimit {
|
||||||
|
s.abortErr(c, game.ErrGameLimitReached)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// handleListGames returns the caller's active and finished games for the lobby.
|
// handleListGames returns the caller's active and finished games for the lobby.
|
||||||
func (s *Server) handleListGames(c *gin.Context) {
|
func (s *Server) handleListGames(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
@@ -400,6 +433,11 @@ func (s *Server) handleListGames(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
memo := map[string]string{}
|
memo := map[string]string{}
|
||||||
out := make([]gameDTO, 0, len(games))
|
out := make([]gameDTO, 0, len(games))
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
@@ -407,7 +445,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
|||||||
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
||||||
out = append(out, dto)
|
out = append(out, dto)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gameListDTO{Games: out})
|
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleChatList returns a game's chat history for the viewer.
|
// handleChatList returns a game's chat history for the viewer.
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
inviteeIDs = append(inviteeIDs, id)
|
inviteeIDs = append(inviteeIDs, id)
|
||||||
}
|
}
|
||||||
|
if !s.ensureUnderGameLimit(c, uid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
|||||||
abortBadRequest(c, "unknown variant")
|
abortBadRequest(c, "unknown variant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !s.ensureUnderGameLimit(c, uid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
enter := s.matchmaker.Enqueue
|
enter := s.matchmaker.Enqueue
|
||||||
if req.VsAI {
|
if req.VsAI {
|
||||||
enter = s.matchmaker.StartVsAI
|
enter = s.matchmaker.StartVsAI
|
||||||
|
|||||||
@@ -467,6 +467,18 @@ disguised robot stays indistinguishable from a person.
|
|||||||
game is `open` the starter may move on their turn, but resign, chat and nudge are
|
game is `open` the starter may move on their turn, but resign, chat and nudge are
|
||||||
refused (no opponent yet) and the lobby and opponent card show a "searching for
|
refused (no opponent yet) and the lobby and opponent card show a "searching for
|
||||||
opponent" placeholder.
|
opponent" placeholder.
|
||||||
|
- **Simultaneous-game cap**: a player may hold at most `game.MaxActiveQuickGames`
|
||||||
|
(**10**) active quick games. `game.Service.CountActiveQuickGames` counts the games
|
||||||
|
seating the account in status `active` or `open` **without** a linked
|
||||||
|
`game_invitations` row — friend games are excluded, and hidden games still occupy a
|
||||||
|
slot, so it is a dedicated count rather than a filter over the lobby list. The backend
|
||||||
|
**gate** (`Server.ensureUnderGameLimit`) refuses **both** new-game entry points at the
|
||||||
|
cap — `POST /lobby/enqueue` and `POST /invitations` — with **409 `game_limit_reached`**;
|
||||||
|
**accepting** an invitation (`POST /invitations/:id/accept`) is never gated, so friend
|
||||||
|
games are capped only at initiation. The lobby learns the state from a boolean
|
||||||
|
**`at_game_limit`** carried on the `games.list` response — the lobby already re-fetches
|
||||||
|
that on entry and on every game event, so the flag needs no separate request or
|
||||||
|
per-event payload; while it is set the client disables **New Game** and shows a notice.
|
||||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
- **Friends**: two add paths over one `friendships` table. A **one-time
|
||||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ is shareable as a Telegram deep link that opens it directly): the inviter choose
|
|||||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
|
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
||||||
|
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
||||||
|
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
||||||
|
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
||||||
|
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
||||||
|
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
||||||
|
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
||||||
|
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
||||||
|
|
||||||
### Playing a game
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
|
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
|
||||||
|
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
|
||||||
|
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
|
||||||
|
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
|
||||||
|
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
|
||||||
|
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
|
||||||
|
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
|
||||||
|
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
|
||||||
|
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
|
||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||||
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
||||||
|
|||||||
@@ -240,6 +240,13 @@ opponent's-turn change is silent. Each card's blink is keyed by game id, so over
|
|||||||
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
||||||
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence).
|
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence).
|
||||||
|
|
||||||
|
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
||||||
|
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
||||||
|
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
||||||
|
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
||||||
|
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
||||||
|
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
||||||
|
|
||||||
## Social, account & history surfaces
|
## Social, account & history surfaces
|
||||||
|
|
||||||
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
||||||
|
|||||||
@@ -353,9 +353,11 @@ type HistoryResp struct {
|
|||||||
Moves []MoveRecordResp `json:"moves"`
|
Moves []MoveRecordResp `json:"moves"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameListResp is the caller's games for the lobby.
|
// GameListResp is the caller's games for the lobby. AtGameLimit is true when the caller
|
||||||
|
// has reached the simultaneous quick-game cap, so the lobby disables "New Game".
|
||||||
type GameListResp struct {
|
type GameListResp struct {
|
||||||
Games []GameResp `json:"games"`
|
Games []GameResp `json:"games"`
|
||||||
|
AtGameLimit bool `json:"at_game_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatListResp is a game's chat history.
|
// ChatListResp is a game's chat history.
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ func encodeGameList(r backendclient.GameListResp) []byte {
|
|||||||
games := b.EndVector(len(gameOffs))
|
games := b.EndVector(len(gameOffs))
|
||||||
fb.GameListStart(b)
|
fb.GameListStart(b)
|
||||||
fb.GameListAddGames(b, games)
|
fb.GameListAddGames(b, games)
|
||||||
|
fb.GameListAddAtGameLimit(b, r.AtGameLimit)
|
||||||
b.Finish(fb.GameListEnd(b))
|
b.Finish(fb.GameListEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games" {
|
if r.URL.Path != "/api/v1/user/games" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -248,6 +248,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if string(seat.DisplayName()) != "Ann" {
|
if string(seat.DisplayName()) != "Ann" {
|
||||||
t.Errorf("seat display name = %q, want Ann", seat.DisplayName())
|
t.Errorf("seat display name = %q, want Ann", seat.DisplayName())
|
||||||
}
|
}
|
||||||
|
if !gl.AtGameLimit() {
|
||||||
|
t.Error("at_game_limit = false, want true (the backend reported the cap)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPassRoundTrip(t *testing.T) {
|
func TestPassRoundTrip(t *testing.T) {
|
||||||
|
|||||||
@@ -313,9 +313,12 @@ table History {
|
|||||||
moves:[MoveRecord];
|
moves:[MoveRecord];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameList is the caller's games (active and finished) for the lobby.
|
// GameList is the caller's games (active and finished) for the lobby. at_game_limit is
|
||||||
|
// true when the caller has reached the simultaneous quick-game cap, so the lobby
|
||||||
|
// disables "New Game" and shows a notice.
|
||||||
table GameList {
|
table GameList {
|
||||||
games:[GameView];
|
games:[GameView];
|
||||||
|
at_game_limit:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- lobby (authenticated) ---
|
// --- lobby (authenticated) ---
|
||||||
|
|||||||
@@ -61,8 +61,20 @@ func (rcv *GameList) GamesLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameList) AtGameLimit() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameList) MutateAtGameLimit(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameListStart(builder *flatbuffers.Builder) {
|
func GameListStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(1)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func GameListAddGames(builder *flatbuffers.Builder, games flatbuffers.UOffsetT) {
|
func GameListAddGames(builder *flatbuffers.Builder, games flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(games), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(games), 0)
|
||||||
@@ -70,6 +82,9 @@ func GameListAddGames(builder *flatbuffers.Builder, games flatbuffers.UOffsetT)
|
|||||||
func GameListStartGamesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func GameListStartGamesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
|
func GameListAddAtGameLimit(builder *flatbuffers.Builder, atGameLimit bool) {
|
||||||
|
builder.PrependBoolSlot(1, atGameLimit, false)
|
||||||
|
}
|
||||||
func GameListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The simultaneous quick-game cap. When the backend reports the player is at the limit
|
||||||
|
// (games.list at_game_limit), the lobby disables the "New Game" tab and shows a plain,
|
||||||
|
// low-emphasis notice under the lists; when it clears, the button re-enables and the
|
||||||
|
// notice goes. Driven by the mock transport's __mock.setGameLimit seam (no backend), which
|
||||||
|
// also nudges the lobby to re-fetch (the lobby refreshes on any live event).
|
||||||
|
|
||||||
|
test('lobby: New Game disables and a notice shows at the simultaneous-game limit', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
|
// Below the limit: the New Game tab is enabled and no notice is shown.
|
||||||
|
const newGame = page.getByRole('button', { name: /New/ });
|
||||||
|
await expect(newGame).toBeEnabled();
|
||||||
|
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
||||||
|
|
||||||
|
// Reach the limit: the button greys out (disabled) and the notice appears under the lists.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(true),
|
||||||
|
);
|
||||||
|
await expect(newGame).toBeDisabled();
|
||||||
|
await expect(page.getByText(/reached the simultaneous games limit/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Drop back below the limit (a game finished): the button re-enables and the notice clears.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(false),
|
||||||
|
);
|
||||||
|
await expect(newGame).toBeEnabled();
|
||||||
|
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -33,8 +33,13 @@ gamesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
atGameLimit():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
static startGameList(builder:flatbuffers.Builder) {
|
static startGameList(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(1);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGames(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset) {
|
static addGames(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset) {
|
||||||
@@ -53,14 +58,19 @@ static startGamesVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addAtGameLimit(builder:flatbuffers.Builder, atGameLimit:boolean) {
|
||||||
|
builder.addFieldInt8(1, +atGameLimit, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endGameList(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGameList(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGameList(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createGameList(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset, atGameLimit:boolean):flatbuffers.Offset {
|
||||||
GameList.startGameList(builder);
|
GameList.startGameList(builder);
|
||||||
GameList.addGames(builder, gamesOffset);
|
GameList.addGames(builder, gamesOffset);
|
||||||
|
GameList.addAtGameLimit(builder, atGameLimit);
|
||||||
return GameList.endGameList(builder);
|
return GameList.endGameList(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ describe('codec', () => {
|
|||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
fb.GameList.addGames(b, games);
|
fb.GameList.addGames(b, games);
|
||||||
|
fb.GameList.addAtGameLimit(b, true);
|
||||||
b.finish(fb.GameList.endGameList(b));
|
b.finish(fb.GameList.endGameList(b));
|
||||||
|
|
||||||
const gl = decodeGameList(b.asUint8Array());
|
const gl = decodeGameList(b.asUint8Array());
|
||||||
@@ -250,6 +251,7 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].seats[0].score).toBe(13);
|
expect(gl.games[0].seats[0].score).toBe(13);
|
||||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||||
expect(gl.games[0].vsAi).toBe(true);
|
expect(gl.games[0].vsAi).toBe(true);
|
||||||
|
expect(gl.atGameLimit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodes an OutgoingRequestList of account refs', () => {
|
it('decodes an OutgoingRequestList of account refs', () => {
|
||||||
|
|||||||
+1
-1
@@ -445,7 +445,7 @@ export function decodeGameList(buf: Uint8Array): GameList {
|
|||||||
const g = gl.games(i);
|
const g = gl.games(i);
|
||||||
if (g) games.push(decodeGameView(g));
|
if (g) games.push(decodeGameView(g));
|
||||||
}
|
}
|
||||||
return { games };
|
return { games, atGameLimit: gl.atGameLimit() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeMatchResult(buf: Uint8Array): MatchResult {
|
export function decodeMatchResult(buf: Uint8Array): MatchResult {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
|
__mock?: {
|
||||||
|
joinOpponent(): void;
|
||||||
|
joinOpponentSilently(): void;
|
||||||
|
adminReply(): void;
|
||||||
|
setGameLimit(v: boolean): void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
).__mock = {
|
).__mock = {
|
||||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||||
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||||
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
||||||
|
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en = {
|
|||||||
'lobby.finishedGames': 'Finished games',
|
'lobby.finishedGames': 'Finished games',
|
||||||
'lobby.noActive': 'No active games yet.',
|
'lobby.noActive': 'No active games yet.',
|
||||||
'lobby.noFinished': 'No finished games yet.',
|
'lobby.noFinished': 'No finished games yet.',
|
||||||
|
'lobby.limitReached': "You've reached the simultaneous games limit.",
|
||||||
'lobby.new': 'New',
|
'lobby.new': 'New',
|
||||||
'lobby.stats': 'Stats',
|
'lobby.stats': 'Stats',
|
||||||
'lobby.profile': 'Profile',
|
'lobby.profile': 'Profile',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'lobby.finishedGames': 'Завершённые игры',
|
'lobby.finishedGames': 'Завершённые игры',
|
||||||
'lobby.noActive': 'Пока нет активных игр.',
|
'lobby.noActive': 'Пока нет активных игр.',
|
||||||
'lobby.noFinished': 'Пока нет завершённых игр.',
|
'lobby.noFinished': 'Пока нет завершённых игр.',
|
||||||
|
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
|
||||||
'lobby.new': 'Новая',
|
'lobby.new': 'Новая',
|
||||||
'lobby.stats': 'Статы',
|
'lobby.stats': 'Статы',
|
||||||
'lobby.profile': 'Профиль',
|
'lobby.profile': 'Профиль',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ beforeEach(() => clearLobby());
|
|||||||
|
|
||||||
describe('patchLobbyGame', () => {
|
describe('patchLobbyGame', () => {
|
||||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
|
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false });
|
||||||
// The player's own move flipped game "a" to the opponent's turn.
|
// The player's own move flipped game "a" to the opponent's turn.
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
const snap = getLobby();
|
const snap = getLobby();
|
||||||
@@ -52,14 +52,14 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('preserves invitations and incoming when patching a game', () => {
|
it('preserves invitations and incoming when patching a game', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming });
|
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false });
|
||||||
patchLobbyGame(gameView('a', 'finished'));
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
expect(getLobby()?.incoming).toEqual(incoming);
|
expect(getLobby()?.incoming).toEqual(incoming);
|
||||||
expect(getLobby()?.games[0].status).toBe('finished');
|
expect(getLobby()?.games[0].status).toBe('finished');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
|
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false });
|
||||||
patchLobbyGame(gameView('z', 'active'));
|
patchLobbyGame(gameView('z', 'active'));
|
||||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||||
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||||
@@ -72,21 +72,29 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('does not mutate the previous snapshot array', () => {
|
it('does not mutate the previous snapshot array', () => {
|
||||||
const games = [gameView('a', 'active', 0)];
|
const games = [gameView('a', 'active', 0)];
|
||||||
setLobby({ games, invitations: [], incoming: [] });
|
setLobby({ games, invitations: [], incoming: [], atGameLimit: false });
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the at-game-limit flag across a game patch', () => {
|
||||||
|
// A finished-game patch arriving while the lobby is unmounted must not drop the flag —
|
||||||
|
// the lobby renders the "New Game" button from the cached snapshot before the refresh.
|
||||||
|
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true });
|
||||||
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
|
expect(getLobby()?.atGameLimit).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('patchLobbyInvitation', () => {
|
describe('patchLobbyInvitation', () => {
|
||||||
it('adds a new pending invitation to the cached lobby', () => {
|
it('adds a new pending invitation to the cached lobby', () => {
|
||||||
setLobby({ games: [], invitations: [], incoming: [] });
|
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||||
patchLobbyInvitation(updated);
|
patchLobbyInvitation(updated);
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||||
@@ -95,14 +103,14 @@ describe('patchLobbyInvitation', () => {
|
|||||||
|
|
||||||
it('removes an invitation that reached a terminal status', () => {
|
it('removes an invitation that reached a terminal status', () => {
|
||||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||||
patchLobbyInvitation(invitation('i1', terminal));
|
patchLobbyInvitation(invitation('i1', terminal));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
});
|
});
|
||||||
@@ -114,7 +122,7 @@ describe('patchLobbyInvitation', () => {
|
|||||||
|
|
||||||
it('preserves games and incoming when patching an invitation', () => {
|
it('preserves games and incoming when patching an invitation', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('g1')], invitations: [], incoming });
|
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||||
expect(getLobby()?.incoming).toEqual(incoming);
|
expect(getLobby()?.incoming).toEqual(incoming);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ interface LobbySnapshot {
|
|||||||
games: GameView[];
|
games: GameView[];
|
||||||
invitations: Invitation[];
|
invitations: Invitation[];
|
||||||
incoming: AccountRef[];
|
incoming: AccountRef[];
|
||||||
|
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
|
||||||
|
// known enabled/disabled state instantly (no flicker), then refreshes it in the background.
|
||||||
|
atGameLimit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot: LobbySnapshot | null = null;
|
let snapshot: LobbySnapshot | null = null;
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export class MockGateway implements GatewayClient {
|
|||||||
private feedbackReplyUnread = false;
|
private feedbackReplyUnread = false;
|
||||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||||
private openGameId: string | null = null;
|
private openGameId: string | null = null;
|
||||||
|
// gameLimit forces the lobby's at_game_limit flag for the e2e (window.__mock.setGameLimit).
|
||||||
|
private gameLimit = false;
|
||||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||||
private outgoing: AccountRef[] = [];
|
private outgoing: AccountRef[] = [];
|
||||||
@@ -152,7 +154,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||||
}
|
}
|
||||||
async gamesList(): Promise<GameList> {
|
async gamesList(): Promise<GameList> {
|
||||||
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
|
return {
|
||||||
|
games: [...this.games.values()].map((g) => structuredClone(g.view)),
|
||||||
|
atGameLimit: this.gameLimit,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- lobby ---
|
// --- lobby ---
|
||||||
@@ -257,6 +262,15 @@ export class MockGateway implements GatewayClient {
|
|||||||
if (this.openGameId) this.seatOpponent(this.openGameId);
|
if (this.openGameId) this.seatOpponent(this.openGameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
|
||||||
|
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
|
||||||
|
// "New Game" button and the notice update in place. The lobby refetches on any
|
||||||
|
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
|
||||||
|
setGameLimit(v: boolean): void {
|
||||||
|
this.gameLimit = v;
|
||||||
|
this.emit({ kind: 'notify', sub: 'game_limit' });
|
||||||
|
}
|
||||||
|
|
||||||
async lobbyPoll(): Promise<MatchResult> {
|
async lobbyPoll(): Promise<MatchResult> {
|
||||||
if (this.pendingMatch) {
|
if (this.pendingMatch) {
|
||||||
const g = this.games.get(this.pendingMatch);
|
const g = this.games.get(this.pendingMatch);
|
||||||
|
|||||||
@@ -293,6 +293,9 @@ export interface History {
|
|||||||
|
|
||||||
export interface GameList {
|
export interface GameList {
|
||||||
games: GameView[];
|
games: GameView[];
|
||||||
|
/** True when the caller has reached the simultaneous quick-game cap; the lobby then
|
||||||
|
* disables "New Game" and shows a notice. */
|
||||||
|
atGameLimit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let invitations = $state<Invitation[]>([]);
|
let invitations = $state<Invitation[]>([]);
|
||||||
let incoming = $state<AccountRef[]>([]);
|
let incoming = $state<AccountRef[]>([]);
|
||||||
|
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
|
||||||
|
// disabled and a notice shows under the lists. It rides the games.list response (which the
|
||||||
|
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
|
||||||
|
// in its last known state instantly and refreshes in the background. Optimistic default
|
||||||
|
// (enabled) for the very first, uncached load; the backend gate is the authority.
|
||||||
|
let atGameLimit = $state(false);
|
||||||
|
|
||||||
const guest = $derived(app.profile?.isGuest ?? true);
|
const guest = $derived(app.profile?.isGuest ?? true);
|
||||||
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
||||||
@@ -25,7 +31,9 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
games = (await gateway.gamesList()).games;
|
const list = await gateway.gamesList();
|
||||||
|
games = list.games;
|
||||||
|
atGameLimit = list.atGameLimit;
|
||||||
if (!guest) {
|
if (!guest) {
|
||||||
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||||
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
|
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
|
||||||
@@ -33,7 +41,7 @@
|
|||||||
app.notifications = incoming.length;
|
app.notifications = incoming.length;
|
||||||
void refreshFeedbackBadge();
|
void refreshFeedbackBadge();
|
||||||
}
|
}
|
||||||
setLobby({ games, invitations, incoming });
|
setLobby({ games, invitations, incoming, atGameLimit });
|
||||||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||||||
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
||||||
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||||||
@@ -51,6 +59,7 @@
|
|||||||
games = cached.games;
|
games = cached.games;
|
||||||
invitations = cached.invitations;
|
invitations = cached.invitations;
|
||||||
incoming = cached.incoming;
|
incoming = cached.incoming;
|
||||||
|
atGameLimit = cached.atGameLimit;
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -167,12 +176,12 @@
|
|||||||
revealedId = null;
|
revealedId = null;
|
||||||
const prev = games;
|
const prev = games;
|
||||||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
||||||
setLobby({ games, invitations, incoming });
|
setLobby({ games, invitations, incoming, atGameLimit });
|
||||||
try {
|
try {
|
||||||
await gateway.hideGame(id);
|
await gateway.hideGame(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
games = prev;
|
games = prev;
|
||||||
setLobby({ games, invitations, incoming });
|
setLobby({ games, invitations, incoming, atGameLimit });
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,11 +288,15 @@
|
|||||||
{#if !games.length && !invitations.length}
|
{#if !games.length && !invitations.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if atGameLimit}
|
||||||
|
<p class="limit">{t('lobby.limitReached')}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/stats')}>
|
<button class="tab" onclick={() => navigate('/stats')}>
|
||||||
@@ -316,6 +329,13 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
|
||||||
|
.limit {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.invite {
|
.invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user