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
+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