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:
@@ -0,0 +1,149 @@
|
||||
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
|
||||
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
|
||||
package gamelimits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
|
||||
type Kind int16
|
||||
|
||||
const (
|
||||
KindUnknown Kind = 0
|
||||
KindVsAI Kind = 1
|
||||
KindRandom Kind = 2
|
||||
KindFriends Kind = 3
|
||||
)
|
||||
|
||||
// String returns the kind's label for admin game lists and logs.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindVsAI:
|
||||
return "vs_ai"
|
||||
case KindRandom:
|
||||
return "random"
|
||||
case KindFriends:
|
||||
return "friends"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Unlimited is the limit sentinel meaning no cap.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits is a tier's active-game caps per kind (-1 = unlimited).
|
||||
type Limits struct {
|
||||
VsAI int
|
||||
Random int
|
||||
Friends int
|
||||
}
|
||||
|
||||
// Cap returns the limit for kind; the unknown kind is never capped.
|
||||
func (l Limits) Cap(kind Kind) int {
|
||||
switch kind {
|
||||
case KindVsAI:
|
||||
return l.VsAI
|
||||
case KindRandom:
|
||||
return l.Random
|
||||
case KindFriends:
|
||||
return l.Friends
|
||||
default:
|
||||
return Unlimited
|
||||
}
|
||||
}
|
||||
|
||||
// Config is the per-tier limit config (the single backend.config row).
|
||||
type Config struct {
|
||||
Guest Limits
|
||||
Durable Limits
|
||||
}
|
||||
|
||||
// Store reads and writes the single-row backend.config.
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
// NewStore constructs a Store over db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) load(ctx context.Context) (Config, error) {
|
||||
var c model.Config
|
||||
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
|
||||
}
|
||||
return Config{
|
||||
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
|
||||
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) save(ctx context.Context, c Config) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
|
||||
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
|
||||
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
|
||||
return fmt.Errorf("gamelimits: save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
|
||||
// once at startup; Update after an admin edit refreshes it in place.
|
||||
type Service struct {
|
||||
store *Store
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store. Call Load before serving.
|
||||
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
|
||||
// Load reads the config into the cache. Call once at startup.
|
||||
func (s *Service) Load(ctx context.Context) error {
|
||||
c, err := s.store.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) set(c Config) {
|
||||
s.mu.Lock()
|
||||
s.cfg = c
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the cached config.
|
||||
func (s *Service) Get() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// LimitsFor returns the cached limits for the account tier (guest or durable).
|
||||
func (s *Service) LimitsFor(isGuest bool) Limits {
|
||||
c := s.Get()
|
||||
if isGuest {
|
||||
return c.Guest
|
||||
}
|
||||
return c.Durable
|
||||
}
|
||||
|
||||
// Update saves the config and refreshes the cache in place (the admin edit).
|
||||
func (s *Service) Update(ctx context.Context, c Config) error {
|
||||
if err := s.store.save(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gamelimits
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
|
||||
// untagged game (game_kind 0) is never gated.
|
||||
func TestLimitsCap(t *testing.T) {
|
||||
l := Limits{VsAI: 1, Random: 2, Friends: 3}
|
||||
for _, tc := range []struct {
|
||||
kind Kind
|
||||
want int
|
||||
}{
|
||||
{KindVsAI, 1},
|
||||
{KindRandom, 2},
|
||||
{KindFriends, 3},
|
||||
{KindUnknown, Unlimited},
|
||||
{Kind(99), Unlimited},
|
||||
} {
|
||||
if got := l.Cap(tc.kind); got != tc.want {
|
||||
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
|
||||
func TestServiceLimitsForTier(t *testing.T) {
|
||||
svc := NewService(nil)
|
||||
svc.set(Config{
|
||||
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||
})
|
||||
|
||||
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
|
||||
t.Errorf("guest limits = %+v, want {1 1 0}", got)
|
||||
}
|
||||
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
|
||||
t.Errorf("durable limits = %+v, want {10 10 10}", got)
|
||||
}
|
||||
// The unlimited sentinel resolves through the tier too.
|
||||
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
|
||||
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
|
||||
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user