games: a ladder you climb against the clock

This commit is contained in:
prosolis
2026-07-14 02:11:09 -07:00
parent feb353f789
commit c62d736223
17 changed files with 2292 additions and 4 deletions

126
internal/web/trivia_bank.go Normal file
View File

@@ -0,0 +1,126 @@
package web
import (
"context"
"log/slog"
"time"
"pete/internal/games/trivia"
"pete/internal/opentdb"
"pete/internal/storage"
)
// Keeping the trivia bank stocked.
//
// The bank is not consumed by play — a question drawn is still there afterwards
// — so this loop is about *variety*, not supply. It fills each difficulty up to
// a target and then has nothing to do, which is why a pass that finds the bank
// full costs three COUNT queries and no network at all.
// bankTarget is how many questions of each difficulty we want to hold. Twelve
// rungs drawn from four hundred is enough that a regular player doesn't start
// recognising them, and it's a size OpenTDB's pool can actually fill.
const bankTarget = 400
// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a
// couple of minutes of drip, after which the loop goes back to sleep rather than
// hammering a free API for an hour to top up the last few questions.
const bankMaxFetches = 12
// bankInterval is how often we go back and look. The bank is a slow-moving
// thing: it only grows, and it only needs to grow once.
const bankInterval = 12 * time.Hour
// StartTriviaBank launches the refill loop if the casino is on. Safe to call
// unconditionally; a no-op when games are off.
func (s *Server) StartTriviaBank(ctx context.Context) {
if !s.gamesReady() {
return
}
go s.runTriviaBank(ctx)
}
func (s *Server) runTriviaBank(ctx context.Context) {
slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval)
s.refillTriviaBank(ctx)
ticker := time.NewTicker(bankInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.refillTriviaBank(ctx)
}
}
}
// refillTriviaBank tops each difficulty up toward the target, politely.
//
// Every failure here is survivable and none of them stop the loop: OpenTDB is a
// free API that is sometimes down, and a thin bank costs a player nothing worse
// than a "give it a minute" when they try to start a ladder.
func (s *Server) refillTriviaBank(ctx context.Context) {
client := opentdb.New()
fetches := 0
for _, t := range trivia.Tiers {
for fetches < bankMaxFetches {
have, err := storage.CountTrivia(t.Difficulty)
if err != nil {
slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err)
break
}
if have >= bankTarget {
break
}
qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch)
fetches++
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err)
// Whatever went wrong, waiting is the only sensible response: the
// likeliest cause is the rate limit, and retrying at once earns another.
if !sleepCtx(ctx, opentdb.Politeness) {
return
}
continue
}
added, err := storage.AddTriviaQuestions(t.Difficulty, qs)
if err != nil {
slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err)
break
}
slog.Info("games: trivia bank filled",
"difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added)
// The API hands back random batches, so once the bank is deep the
// overlap gets heavy and a batch adds almost nothing new. When it adds
// nothing at all, this difficulty has given us what it has: stop asking.
if added == 0 {
break
}
if !sleepCtx(ctx, opentdb.Politeness) {
return
}
}
}
}
// sleepCtx waits, unless we're being shut down. Reports false if we are.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}