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
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"},
|
||||
"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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user