148 lines
4.5 KiB
Go
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
|
|
}
|