Files
Pete/internal/opentdb/opentdb.go
2026-07-14 02:11:09 -07:00

167 lines
5.1 KiB
Go

// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
//
// The questions are *prefetched* into a local table, not fetched per question,
// and that is a deliberate call rather than an optimisation. A trivia ladder
// asks a question every fifteen seconds with money on the clock: a per-question
// fetch would put somebody else's latency, rate limit and downtime inside a
// timed round the player is being scored against. Pull the bank in the
// background, and a round becomes a local read that either works or doesn't.
//
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
// so the refill is a slow, polite drip, run in the background and never in the
// path of anything a player is waiting for.
package opentdb
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/games/trivia"
"pete/internal/safehttp"
)
// endpoint is the API. It is the only host this package ever talks to, and it
// goes through safehttp like every other outbound fetch in Pete.
const endpoint = "https://opentdb.com/api.php"
// Batch is the most OpenTDB will hand over in one request.
const Batch = 50
// Politeness is the gap the API asks for between requests. Going faster earns a
// response_code 5 and nothing else.
const Politeness = 6 * time.Second
// fetchTimeout bounds a single request. The refill runs in the background, so a
// slow answer costs nothing but its own goroutine — but it must still end.
const fetchTimeout = 20 * time.Second
// maxBody caps what we will read from the API, hostile or merely broken.
const maxBody = 1 << 20
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
// zero is the only one that means "here are your questions".
type apiResponse struct {
ResponseCode int `json:"response_code"`
Results []struct {
Category string `json:"category"`
Type string `json:"type"`
Question string `json:"question"`
Correct string `json:"correct_answer"`
Incorrect []string `json:"incorrect_answers"`
} `json:"results"`
}
// responseErr turns a non-zero code into something a log line can explain.
func responseErr(code int) error {
switch code {
case 1:
return fmt.Errorf("opentdb: no results for that query")
case 2:
return fmt.Errorf("opentdb: the query was invalid")
case 3, 4:
return fmt.Errorf("opentdb: session token expired or exhausted")
case 5:
return fmt.Errorf("opentdb: rate limited — slow down")
default:
return fmt.Errorf("opentdb: response code %d", code)
}
}
// Client fetches questions.
type Client struct {
http *http.Client
}
func New() *Client {
return &Client{http: safehttp.NewClient(fetchTimeout)}
}
// Fetch pulls up to n multiple-choice questions of one difficulty.
//
// Only "multiple" questions are asked for: the ladder is four buttons, and a
// true/false question on the same felt would be a coin flip dressed up as a
// question — and a coin flip the player is being paid a difficulty multiple for.
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
if n <= 0 || n > Batch {
n = Batch
}
q := url.Values{
"amount": {fmt.Sprint(n)},
"difficulty": {difficulty},
"type": {"multiple"},
}
raw := endpoint + "?" + q.Encode()
if err := safehttp.ValidateURL(raw); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
}
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
if err != nil {
return nil, err
}
var out apiResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("opentdb: %w", err)
}
if out.ResponseCode != 0 {
return nil, responseErr(out.ResponseCode)
}
qs := make([]trivia.Question, 0, len(out.Results))
for _, r := range out.Results {
// The API hands back HTML entities ("Who wrote &quot;Dune&quot;?"), which
// would otherwise be drawn literally onto a button.
text := clean(r.Question)
correct := clean(r.Correct)
if text == "" || correct == "" || len(r.Incorrect) != 3 {
continue // a malformed question is one we simply don't take
}
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
// question against the game's own seed as it builds the ladder, so where
// the right answer sits in the bank never reaches a player.
answers := make([]string, 0, 4)
answers = append(answers, correct)
for _, w := range r.Incorrect {
answers = append(answers, clean(w))
}
qs = append(qs, trivia.Question{
Category: clean(r.Category),
Text: text,
Answers: answers,
Correct: 0,
})
}
return qs, nil
}
// clean turns an API string into something you can put on a button: entities
// decoded, whitespace tidied.
func clean(s string) string {
return strings.TrimSpace(html.UnescapeString(s))
}