feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random, friends) with independent guest and durable-account tiers, held in a new single-row backend.config table (-1 = unlimited) behind an in-memory cache and editable live in the admin console (/_gm/limits). Each game is tagged with games.game_kind on creation. This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the per-tier/kind config is the single mechanism, enforced at the same handler gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the tier and counts; the limit policy stays at the request edge. Guests are now refused friend requests, friend-code redemption, befriend-in-game and invitation creation outright (403 guest_forbidden) -- previously only the UI hid these. Admin: a kind column in both game lists and the config editor. Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -57,6 +58,9 @@ type Service struct {
|
||||
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
||||
// wallet-free and never touch it.
|
||||
hintWallet HintWallet
|
||||
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
|
||||
// when nil, no active-game limit is enforced (a game is always creatable).
|
||||
limits *gamelimits.Service
|
||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||
// best-effort and kept as a func so the game package never imports the social package.
|
||||
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
|
||||
svc.hintWallet = w
|
||||
}
|
||||
|
||||
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
|
||||
// unset (nil), a game is always creatable.
|
||||
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
|
||||
svc.limits = l
|
||||
}
|
||||
|
||||
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
|
||||
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
|
||||
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
|
||||
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
|
||||
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
|
||||
// game_limit_reached) and the lobby's at-limit flag.
|
||||
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
|
||||
if svc.limits == nil || accountID == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
|
||||
if limit == gamelimits.Unlimited {
|
||||
return false, nil
|
||||
}
|
||||
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n >= limit, nil
|
||||
}
|
||||
|
||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
||||
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
||||
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
kind: params.Kind,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||
return Game{}, err
|
||||
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
kind: gamelimits.KindRandom,
|
||||
}
|
||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||
@@ -1386,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
@@ -45,6 +46,8 @@ type gameInsert struct {
|
||||
multipleWordsPerTurn bool
|
||||
// vsAI marks an honest-AI game (games.vs_ai).
|
||||
vsAI bool
|
||||
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||
kind gamelimits.Kind
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
|
||||
})
|
||||
}
|
||||
|
||||
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
|
||||
// per-tier active-game limit is checked against it before a new game of that kind is created.
|
||||
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM backend.games g
|
||||
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
|
||||
accountID, int16(kind)).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
@@ -501,28 +518,6 @@ 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 {
|
||||
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
}
|
||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||
out.VsAI = g.VsAi
|
||||
out.Kind = gamelimits.Kind(g.GameKind)
|
||||
if g.EndReason != nil {
|
||||
out.EndReason = *g.EndReason
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// Status values persisted in the games.status column.
|
||||
@@ -94,15 +95,6 @@ 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).
|
||||
@@ -125,6 +117,10 @@ type CreateParams struct {
|
||||
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
||||
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
||||
VsAI bool
|
||||
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
|
||||
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Game is the persisted state of a match: the games row joined with its seats.
|
||||
@@ -151,6 +147,9 @@ type Game struct {
|
||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||
VsAI bool
|
||||
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Seat is one player's standing in a game.
|
||||
|
||||
Reference in New Issue
Block a user