63ab85a5e5
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
189 lines
7.3 KiB
Go
189 lines
7.3 KiB
Go
//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
|
|
}
|