games: a ladder you climb against the clock
This commit is contained in:
@@ -242,6 +242,31 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||
-- so that asking one is a local read.
|
||||
--
|
||||
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||
-- question every fifteen seconds with money on a clock the player is scored
|
||||
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||
-- never waits on it.
|
||||
--
|
||||
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||
-- happens in the engine, per game, against that game's seed — so where the right
|
||||
-- answer sits in this table tells a player nothing.
|
||||
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||
category TEXT NOT NULL,
|
||||
question TEXT NOT NULL UNIQUE,
|
||||
correct TEXT NOT NULL,
|
||||
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
|
||||
147
internal/storage/trivia.go
Normal file
147
internal/storage/trivia.go
Normal file
@@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user