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.
|
||||
|
||||
Reference in New Issue
Block a user