// 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 "Dune"?"), 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)) }