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 }