feat(lobby): cap simultaneous quick games at 10
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Limit a player to 10 active quick games (auto-match + AI); friend games created
by invitation are not counted. At the cap the backend refuses both new-game
entry points — quick enqueue and invitation creation — with 409
game_limit_reached, while accepting an incoming invitation stays allowed, so
friend games are capped from the other end. The lobby disables "New Game" and
shows a low-emphasis notice, driven by a new at_game_limit flag on games.list
(no per-event payload: a turn change does not move the count, and the lobby
already re-fetches games.list on entry and every game event).

- game.MaxActiveQuickGames + Store/Service.CountActiveQuickGames (active/open
  seats, no game_invitations row; hidden games still count -> dedicated count)
- Server.ensureUnderGameLimit gating handleEnqueue + handleCreateInvitation;
  game.ErrGameLimitReached -> 409 game_limit_reached
- FB GameList.at_game_limit (regenerated Go + TS) through the gateway transcode
  and UI codec; gameListDTO + lobbycache snapshot + Lobby.svelte + i18n
- tests: integration count rule + HTTP gate + accept bypass; server error map;
  gateway transcode round-trip; UI codec + lobbycache unit; e2e gamelimit
- docs: PRERELEASE (GL), FUNCTIONAL(+ru), ARCHITECTURE 8, UI_DESIGN, backend README
This commit is contained in:
Ilia Denisov
2026-06-16 22:51:18 +02:00
parent 05d83ced86
commit 63ab85a5e5
32 changed files with 496 additions and 28 deletions
+25
View File
@@ -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** |
| 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) |
| 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) |
## 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
(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.
- **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 12 (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
View File
@@ -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
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 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
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. `internal/account`
+8
View File
@@ -1178,6 +1178,14 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
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
// 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.
+22
View File
@@ -450,6 +450,28 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
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
// game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
+12
View File
@@ -65,6 +65,9 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
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
@@ -87,6 +90,15 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI).
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
// 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).
+188
View File
@@ -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
}
+1
View File
@@ -34,6 +34,7 @@ func TestStatusForError(t *testing.T) {
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
"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"},
}
for name, tc := range cases {
+2
View File
@@ -174,6 +174,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active"
case errors.Is(err, game.ErrGameLimitReached):
return http.StatusConflict, "game_limit_reached"
case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed):
+41 -3
View File
@@ -44,9 +44,14 @@ type historyDTO struct {
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 {
Games []gameDTO `json:"games"`
Games []gameDTO `json:"games"`
AtGameLimit bool `json:"at_game_limit"`
}
// 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})
}
// 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.
func (s *Server) handleListGames(c *gin.Context) {
uid, ok := userID(c)
@@ -400,6 +433,11 @@ func (s *Server) handleListGames(c *gin.Context) {
s.abortErr(c, err)
return
}
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
out := make([]gameDTO, 0, len(games))
for _, g := range games {
@@ -407,7 +445,7 @@ func (s *Server) handleListGames(c *gin.Context) {
s.fillSeatNames(c.Request.Context(), &dto, memo)
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.
@@ -130,6 +130,9 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
}
inviteeIDs = append(inviteeIDs, id)
}
if !s.ensureUnderGameLimit(c, uid) {
return
}
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
if err != nil {
s.abortErr(c, err)
+3
View File
@@ -161,6 +161,9 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant")
return
}
if !s.ensureUnderGameLimit(c, uid) {
return
}
enter := s.matchmaker.Enqueue
if req.VsAI {
enter = s.matchmaker.StartVsAI
+12
View File
@@ -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
refused (no opponent yet) and the lobby and opponent card show a "searching for
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
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
+9
View File
@@ -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
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
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
+10
View File
@@ -115,6 +115,16 @@ nudge) приходят от бота **этой партии** — по язы
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
+7
View File
@@ -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
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
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
+4 -2
View File
@@ -353,9 +353,11 @@ type HistoryResp struct {
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 {
Games []GameResp `json:"games"`
Games []GameResp `json:"games"`
AtGameLimit bool `json:"at_game_limit"`
}
// ChatListResp is a game's chat history.
+1
View File
@@ -352,6 +352,7 @@ func encodeGameList(r backendclient.GameListResp) []byte {
games := b.EndVector(len(gameOffs))
fb.GameListStart(b)
fb.GameListAddGames(b, games)
fb.GameListAddAtGameLimit(b, r.AtGameLimit)
b.Finish(fb.GameListEnd(b))
return b.FinishedBytes()
}
+4 -1
View File
@@ -221,7 +221,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" {
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()
@@ -248,6 +248,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if string(seat.DisplayName()) != "Ann" {
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) {
+4 -1
View File
@@ -313,9 +313,12 @@ table History {
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 {
games:[GameView];
at_game_limit:bool;
}
// --- lobby (authenticated) ---
+16 -1
View File
@@ -61,8 +61,20 @@ func (rcv *GameList) GamesLength() int {
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) {
builder.StartObject(1)
builder.StartObject(2)
}
func GameListAddGames(builder *flatbuffers.Builder, games flatbuffers.UOffsetT) {
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 {
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 {
return builder.EndObject()
}
+31
View File
@@ -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);
});
+12 -2
View File
@@ -33,8 +33,13 @@ gamesLength():number {
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) {
builder.startObject(1);
builder.startObject(2);
}
static addGames(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset) {
@@ -53,14 +58,19 @@ static startGamesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addAtGameLimit(builder:flatbuffers.Builder, atGameLimit:boolean) {
builder.addFieldInt8(1, +atGameLimit, +false);
}
static endGameList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
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.addGames(builder, gamesOffset);
GameList.addAtGameLimit(builder, atGameLimit);
return GameList.endGameList(builder);
}
}
+2
View File
@@ -241,6 +241,7 @@ describe('codec', () => {
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
fb.GameList.addGames(b, games);
fb.GameList.addAtGameLimit(b, true);
b.finish(fb.GameList.endGameList(b));
const gl = decodeGameList(b.asUint8Array());
@@ -250,6 +251,7 @@ describe('codec', () => {
expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
expect(gl.atGameLimit).toBe(true);
});
it('decodes an OutgoingRequestList of account refs', () => {
+1 -1
View File
@@ -445,7 +445,7 @@ export function decodeGameList(buf: Uint8Array): GameList {
const g = gl.games(i);
if (g) games.push(decodeGameView(g));
}
return { games };
return { games, atGameLimit: gl.atGameLimit() };
}
export function decodeMatchResult(buf: Uint8Array): MatchResult {
+7 -1
View File
@@ -24,11 +24,17 @@ if (isMock && typeof window !== 'undefined') {
// attaches a robot on a timer).
(
window as unknown as {
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
__mock?: {
joinOpponent(): void;
joinOpponentSilently(): void;
adminReply(): void;
setGameLimit(v: boolean): void;
};
}
).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
};
}
+1
View File
@@ -33,6 +33,7 @@ export const en = {
'lobby.finishedGames': 'Finished games',
'lobby.noActive': 'No active games yet.',
'lobby.noFinished': 'No finished games yet.',
'lobby.limitReached': "You've reached the simultaneous games limit.",
'lobby.new': 'New',
'lobby.stats': 'Stats',
'lobby.profile': 'Profile',
+1
View File
@@ -34,6 +34,7 @@ export const ru: Record<MessageKey, string> = {
'lobby.finishedGames': 'Завершённые игры',
'lobby.noActive': 'Пока нет активных игр.',
'lobby.noFinished': 'Пока нет завершённых игр.',
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
'lobby.new': 'Новая',
'lobby.stats': 'Статы',
'lobby.profile': 'Профиль',
+17 -9
View File
@@ -41,7 +41,7 @@ beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
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.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby();
@@ -52,14 +52,14 @@ describe('patchLobbyGame', () => {
it('preserves invitations and incoming when patching a game', () => {
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'));
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
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'));
// 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']);
@@ -72,21 +72,29 @@ describe('patchLobbyGame', () => {
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [] });
setLobby({ games, invitations: [], incoming: [], atGameLimit: false });
patchLobbyGame(gameView('a', 'active', 1));
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', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [] });
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
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 };
patchLobbyInvitation(updated);
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', () => {
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));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
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'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
@@ -114,7 +122,7 @@ describe('patchLobbyInvitation', () => {
it('preserves games and incoming when patching an invitation', () => {
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'));
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
+3
View File
@@ -10,6 +10,9 @@ interface LobbySnapshot {
games: GameView[];
invitations: Invitation[];
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;
+15 -1
View File
@@ -98,6 +98,8 @@ export class MockGateway implements GatewayClient {
private feedbackReplyUnread = false;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
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 incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = [];
@@ -152,7 +154,10 @@ export class MockGateway implements GatewayClient {
return { blocked: false, permanent: false, until: '', reason: '' };
}
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 ---
@@ -257,6 +262,15 @@ export class MockGateway implements GatewayClient {
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> {
if (this.pendingMatch) {
const g = this.games.get(this.pendingMatch);
+3
View File
@@ -293,6 +293,9 @@ export interface History {
export interface GameList {
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;
}
/**
+25 -5
View File
@@ -17,6 +17,12 @@
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
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);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
@@ -25,7 +31,9 @@
async function load() {
try {
games = (await gateway.gamesList()).games;
const list = await gateway.gamesList();
games = list.games;
atGameLimit = list.atGameLimit;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
@@ -33,7 +41,7 @@
app.notifications = incoming.length;
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
// 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).
@@ -51,6 +59,7 @@
games = cached.games;
invitations = cached.invitations;
incoming = cached.incoming;
atGameLimit = cached.atGameLimit;
}
void load();
});
@@ -167,12 +176,12 @@
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming });
setLobby({ games, invitations, incoming, atGameLimit });
try {
await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming });
setLobby({ games, invitations, incoming, atGameLimit });
handleError(e);
}
}
@@ -279,11 +288,15 @@
{#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
{#if atGameLimit}
<p class="limit">{t('lobby.limitReached')}</p>
{/if}
</div>
{#snippet 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>
</button>
<button class="tab" onclick={() => navigate('/stats')}>
@@ -316,6 +329,13 @@
font-size: 0.9rem;
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 {
display: flex;
align-items: center;