Files
Pete/internal/opentdb/opentdb.go
prosolis 3e9b93af55 games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the
timeout only ever bit if the browser volunteered it. Sit on a question,
look it up, answer if you find it and walk if you don't, and you never
lose a ladder. The clock is now the first thing that happens to a move.

The house's chip rack was wired up as bet buttons on blackjack and
hangman: it's four spans with data-chip on them and nothing said the
handler only wanted the real ones. Clicking the house's money raised
your bet.

Hangman had two definitions of "a letter you'd guess" — unicode in the
engine, ASCII in the renderer — and a phrase with an accent in it would
have had no tile to fill and no key to fill it with. One definition now.

Plus: trivia's countdown no longer freezes at zero when the server turns
down a timeout report it was early for, questions whose wrong answer
decodes into the right one are dropped at the door, and hangman bets on
PeteFX's spot like every other table instead of its own copy of it.
2026-07-14 06:28:38 -07:00

181 lines
5.6 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)
dupe := false
for _, w := range r.Incorrect {
a := clean(w)
// A wrong answer that reads the same as the right one — usually two
// spellings that collapse once the entities are decoded — is a question
// with two identical buttons on it, and the shuffle can only call one of
// them correct. A player who clicked the right words and was told they
// were wrong has lost the whole ladder to our typography. Drop it.
if a == "" || a == correct {
dupe = true
break
}
answers = append(answers, a)
}
if dupe || len(answers) != 4 {
continue
}
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))
}