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:
@@ -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