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

148 lines
4.5 KiB
Go

package storage
import (
"encoding/json"
"fmt"
"math/rand/v2"
"time"
"pete/internal/games/trivia"
)
// The trivia bank.
//
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
// drawn from here when a ladder is built. Nothing in a player's round ever
// touches the network.
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
// build a ladder. It is a real state, not a bug: a fresh database has an empty
// bank until the refill loop has been round a few times.
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
// UNIQUE on the text is what stops the bank becoming forty questions deep.
// Returns how many were actually new, which is what the refill loop logs.
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
if len(qs) == 0 {
return 0, nil
}
tx, err := Get().Begin()
if err != nil {
return 0, fmt.Errorf("trivia: begin: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
stmt, err := tx.Prepare(
`INSERT OR IGNORE INTO trivia_questions
(difficulty, category, question, correct, incorrect, fetched_at)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return 0, fmt.Errorf("trivia: prepare: %w", err)
}
defer stmt.Close()
now := time.Now().Unix()
added := 0
for _, q := range qs {
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
continue
}
correct := q.Answers[q.Correct]
wrong := make([]string, 0, len(q.Answers)-1)
for i, a := range q.Answers {
if i != q.Correct {
wrong = append(wrong, a)
}
}
blob, err := json.Marshal(wrong)
if err != nil {
continue
}
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
if err != nil {
return added, fmt.Errorf("trivia: insert: %w", err)
}
if n, err := res.RowsAffected(); err == nil {
added += int(n)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("trivia: commit: %w", err)
}
return added, nil
}
// CountTrivia is how many questions of a difficulty the bank holds. The refill
// loop reads it to decide whether to bother.
func CountTrivia(difficulty string) (int, error) {
var n int
if err := Get().QueryRow(
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
).Scan(&n); err != nil {
return 0, fmt.Errorf("trivia: count: %w", err)
}
return n, nil
}
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
// the game's own rng.
//
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
// in the audit log means something: the same seed against the same bank deals
// the same ladder, which is what lets a disputed game be replayed. It reads the
// ids first and picks from them, so a bank of a few thousand questions costs one
// small scan rather than a sort of the whole table.
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
if n <= 0 {
return nil, nil
}
rows, err := Get().Query(
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
if err != nil {
return nil, fmt.Errorf("trivia: draw ids: %w", err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("trivia: scan id: %w", err)
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("trivia: draw ids: %w", err)
}
if len(ids) < n {
return nil, ErrBankEmpty
}
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
pick := ids[:n]
out := make([]trivia.Question, 0, n)
for _, id := range pick {
var q trivia.Question
var correct, blob string
if err := Get().QueryRow(
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
return nil, fmt.Errorf("trivia: load question: %w", err)
}
var wrong []string
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
}
// Correct: 0 is a convention the engine immediately destroys — New()
// reshuffles every question against the game's seed. Nothing that reaches a
// player depends on the order they come out of the table in.
q.Answers = append([]string{correct}, wrong...)
q.Correct = 0
out = append(out, q)
}
return out, nil
}