games: a ladder you climb against the clock
This commit is contained in:
166
internal/opentdb/opentdb.go
Normal file
166
internal/opentdb/opentdb.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user