ed53e25e57
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.
150 lines
3.8 KiB
Go
150 lines
3.8 KiB
Go
// 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
|
|
}
|