games: a ladder you climb against the clock

This commit is contained in:
prosolis
2026-07-14 02:11:09 -07:00
parent feb353f789
commit c62d736223
17 changed files with 2292 additions and 4 deletions

View File

@@ -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
View 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
}