// 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 }