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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user