Files
gogobee/internal/plugin/trivia.go
prosolis cce5160057 Add games system: euro economy, Hangman, Blackjack, coin flip
Euro economy: virtual currency earned passively per message (tiered by
word count, 30s cooldown). Starting balance seeded from corpus character
count. Balance, leaderboard, and transfer commands. Debt system with
configurable limit.

Hangman: collaborative game with phrase pool loaded from file. ASCII
gallows display, tiered scoring (Easy/Medium/Hard/Extreme), early
solution bonus, participant tracking with DM verification. Community
phrase submission with LLM screening.

Blackjack: 1-2 players vs dealer. Standard casino rules (hit soft 17).
Auto-play on timeout. Bet validation against balance and debt limit.
Blackjack pays 1.5x. Separate leaderboard.

Games channel restriction: all game commands (trivia, hangman, blackjack,
flip) restricted to GAMES_ROOM. Dice (!roll) exempt. Trivia also
restricted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:30:17 -07:00

551 lines
15 KiB
Go

package plugin
import (
"encoding/json"
"fmt"
"html"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"os"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// categoryMap maps category IDs to human-readable names.
var categoryMap = map[int]string{
9: "General Knowledge",
10: "Books",
11: "Film",
12: "Music",
15: "Video Games",
17: "Science & Nature",
18: "Computers",
21: "Sports",
22: "Geography",
23: "History",
}
// categoryNameToID maps lowercase category names to IDs.
var categoryNameToID = map[string]int{
"general": 9,
"books": 10,
"film": 11,
"movie": 11,
"movies": 11,
"music": 12,
"games": 15,
"gaming": 15,
"science": 17,
"computers": 18,
"tech": 18,
"sports": 21,
"geography": 22,
"geo": 22,
"history": 23,
}
type openTDBResponse struct {
ResponseCode int `json:"response_code"`
Results []openTDBResult `json:"results"`
}
type openTDBResult struct {
Category string `json:"category"`
Type string `json:"type"`
Difficulty string `json:"difficulty"`
Question string `json:"question"`
CorrectAnswer string `json:"correct_answer"`
IncorrectAnswers []string `json:"incorrect_answers"`
}
type activeSession struct {
RoomID id.RoomID
QuestionEventID id.EventID
Question string
CorrectAnswer string
AllAnswers []string
CorrectIndex int
StartedAt time.Time
Difficulty string
Category string
}
// TriviaPlugin handles trivia game sessions.
type TriviaPlugin struct {
Base
mu sync.Mutex
sessions map[id.RoomID]*activeSession
enabled bool
}
// NewTriviaPlugin creates a new TriviaPlugin.
func NewTriviaPlugin(client *mautrix.Client) *TriviaPlugin {
return &TriviaPlugin{
Base: NewBase(client),
sessions: make(map[id.RoomID]*activeSession),
enabled: os.Getenv("FEATURE_TRIVIA") != "false",
}
}
func (p *TriviaPlugin) Name() string { return "trivia" }
func (p *TriviaPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "trivia", Description: "Start a trivia question", Usage: "!trivia [category] [easy|medium|hard]", Category: "Fun & Games"},
{Name: "trivia scores", Description: "Room trivia leaderboard", Usage: "!trivia scores", Category: "Fun & Games"},
{Name: "trivia categories", Description: "List available categories", Usage: "!trivia categories", Category: "Fun & Games"},
{Name: "trivia fastest", Description: "Fastest correct answers", Usage: "!trivia fastest", Category: "Fun & Games"},
{Name: "trivia stop", Description: "Stop current trivia session", Usage: "!trivia stop", Category: "Fun & Games"},
}
}
func (p *TriviaPlugin) Init() error { return nil }
func (p *TriviaPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *TriviaPlugin) OnMessage(ctx MessageContext) error {
if !p.enabled {
if p.IsCommand(ctx.Body, "trivia") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Trivia is disabled.")
}
return nil
}
if p.IsCommand(ctx.Body, "trivia") {
if !isGamesRoom(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
}
args := p.GetArgs(ctx.Body, "trivia")
return p.handleTrivia(ctx, args)
}
// Check if this is a thread reply to an active trivia question
p.mu.Lock()
session, ok := p.sessions[ctx.RoomID]
p.mu.Unlock()
if ok && session != nil {
// Only accept answers in the trivia thread
content := ctx.Event.Content.AsMessage()
if content != nil && content.RelatesTo != nil &&
content.RelatesTo.Type == event.RelThread &&
content.RelatesTo.EventID == session.QuestionEventID {
return p.handleAnswer(ctx, session)
}
}
return nil
}
func (p *TriviaPlugin) handleTrivia(ctx MessageContext, args string) error {
lower := strings.ToLower(strings.TrimSpace(args))
switch {
case lower == "scores":
return p.showScores(ctx)
case lower == "categories":
return p.showCategories(ctx)
case lower == "fastest":
return p.showFastest(ctx)
case lower == "stop":
return p.stopSession(ctx)
default:
return p.startQuestion(ctx, args)
}
}
func (p *TriviaPlugin) showCategories(ctx MessageContext) error {
var sb strings.Builder
sb.WriteString("📚 Trivia Categories:\n")
for catID, name := range categoryMap {
sb.WriteString(fmt.Sprintf(" • %s (%d)\n", name, catID))
}
sb.WriteString("\nUsage: !trivia [category] [easy|medium|hard]")
return p.SendMessage(ctx.RoomID, sb.String())
}
func (p *TriviaPlugin) showScores(ctx MessageContext) error {
rows, err := db.Get().Query(
`SELECT user_id, correct, wrong, total_score
FROM trivia_scores
WHERE room_id = ?
ORDER BY total_score DESC
LIMIT 10`,
string(ctx.RoomID),
)
if err != nil {
slog.Error("trivia: query scores", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to fetch scores.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🏆 Trivia Leaderboard:\n")
rank := 0
found := false
for rows.Next() {
var userID string
var correct, wrong, totalScore int
if err := rows.Scan(&userID, &correct, &wrong, &totalScore); err != nil {
continue
}
found = true
rank++
sb.WriteString(fmt.Sprintf("%d. %s — %d pts (%d correct, %d wrong)\n", rank, userID, totalScore, correct, wrong))
}
if !found {
return p.SendMessage(ctx.RoomID, "No trivia scores yet in this room!")
}
return p.SendMessage(ctx.RoomID, sb.String())
}
func (p *TriviaPlugin) showFastest(ctx MessageContext) error {
rows, err := db.Get().Query(
`SELECT user_id, fastest_ms
FROM trivia_scores
WHERE room_id = ? AND fastest_ms > 0
ORDER BY fastest_ms ASC
LIMIT 10`,
string(ctx.RoomID),
)
if err != nil {
slog.Error("trivia: query fastest", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to fetch fastest times.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("⚡ Fastest Correct Answers:\n")
rank := 0
found := false
for rows.Next() {
var userID string
var fastestMs int
if err := rows.Scan(&userID, &fastestMs); err != nil {
continue
}
found = true
rank++
sb.WriteString(fmt.Sprintf("%d. %s — %.2fs\n", rank, userID, float64(fastestMs)/1000.0))
}
if !found {
return p.SendMessage(ctx.RoomID, "No fastest times recorded yet!")
}
return p.SendMessage(ctx.RoomID, sb.String())
}
func (p *TriviaPlugin) stopSession(ctx MessageContext) error {
p.mu.Lock()
_, ok := p.sessions[ctx.RoomID]
if ok {
delete(p.sessions, ctx.RoomID)
}
p.mu.Unlock()
if !ok {
return p.SendMessage(ctx.RoomID, "No active trivia session in this room.")
}
return p.SendMessage(ctx.RoomID, "Trivia session stopped.")
}
func (p *TriviaPlugin) startQuestion(ctx MessageContext, args string) error {
p.mu.Lock()
if _, ok := p.sessions[ctx.RoomID]; ok {
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, "A trivia question is already active! Answer it or use !trivia stop.")
}
p.mu.Unlock()
// Parse category and difficulty from args
category := 0
difficulty := ""
parts := strings.Fields(strings.ToLower(args))
for _, part := range parts {
if part == "easy" || part == "medium" || part == "hard" {
difficulty = part
continue
}
if catID, ok := categoryNameToID[part]; ok {
category = catID
}
}
// Build API URL
apiURL := "https://opentdb.com/api.php?amount=1"
if category > 0 {
apiURL += fmt.Sprintf("&category=%d", category)
}
if difficulty != "" {
apiURL += fmt.Sprintf("&difficulty=%s", difficulty)
}
resp, err := http.Get(apiURL)
if err != nil {
slog.Error("trivia: API request failed", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to fetch trivia question. Try again later.")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("trivia: read response", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to read trivia response.")
}
var apiResp openTDBResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
slog.Error("trivia: parse response", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to parse trivia response.")
}
if apiResp.ResponseCode != 0 || len(apiResp.Results) == 0 {
return p.SendMessage(ctx.RoomID, "No trivia questions available for those criteria. Try different options!")
}
result := apiResp.Results[0]
question := html.UnescapeString(result.Question)
correctAnswer := html.UnescapeString(result.CorrectAnswer)
// Build answer list
var allAnswers []string
correctIndex := 0
if result.Type == "boolean" {
allAnswers = []string{"True", "False"}
if correctAnswer == "False" {
correctIndex = 1
}
} else {
// Shuffle answers
incorrectAnswers := make([]string, len(result.IncorrectAnswers))
for i, a := range result.IncorrectAnswers {
incorrectAnswers[i] = html.UnescapeString(a)
}
allAnswers = append(incorrectAnswers, correctAnswer)
// Fisher-Yates shuffle
for i := len(allAnswers) - 1; i > 0; i-- {
j := rand.IntN(i + 1)
allAnswers[i], allAnswers[j] = allAnswers[j], allAnswers[i]
}
for i, a := range allAnswers {
if a == correctAnswer {
correctIndex = i
break
}
}
}
// Format question message
diffLabel := html.UnescapeString(result.Difficulty)
catLabel := html.UnescapeString(result.Category)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧠 **Trivia** [%s / %s]\n\n", catLabel, diffLabel))
sb.WriteString(fmt.Sprintf("%s\n\n", question))
letters := []string{"A", "B", "C", "D"}
for i, ans := range allAnswers {
if i < len(letters) {
sb.WriteString(fmt.Sprintf(" **%s.** %s\n", letters[i], ans))
}
}
sb.WriteString("\nReply with A, B, C, or D (or True/False). You have 30 seconds!")
msgText := sb.String()
// Store in DB
incorrectJSON, _ := json.Marshal(result.IncorrectAnswers)
_, err = db.Get().Exec(
`INSERT INTO trivia_sessions (room_id, category, difficulty, question, correct_answer, incorrect_answers, question_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
string(ctx.RoomID), category, result.Difficulty, question, correctAnswer, string(incorrectJSON), result.Type,
)
if err != nil {
slog.Error("trivia: insert session", "err", err)
}
// Send thread root announcement, then post the question in the thread
threadRootID, err := p.SendMessageID(ctx.RoomID, "🧠 Trivia time! Reply in thread to answer.")
if err != nil {
return err
}
if err := p.SendThread(ctx.RoomID, threadRootID, msgText); err != nil {
return err
}
session := &activeSession{
RoomID: ctx.RoomID,
QuestionEventID: threadRootID,
Question: question,
CorrectAnswer: correctAnswer,
AllAnswers: allAnswers,
CorrectIndex: correctIndex,
StartedAt: time.Now(),
Difficulty: result.Difficulty,
Category: catLabel,
}
p.mu.Lock()
p.sessions[ctx.RoomID] = session
p.mu.Unlock()
// Auto-expire after 30 seconds
go func() {
time.Sleep(30 * time.Second)
p.mu.Lock()
current, ok := p.sessions[ctx.RoomID]
if ok && current == session {
delete(p.sessions, ctx.RoomID)
p.mu.Unlock()
letters := []string{"A", "B", "C", "D"}
ansLetter := ""
if session.CorrectIndex < len(letters) {
ansLetter = letters[session.CorrectIndex] + ". "
}
_ = p.SendThread(ctx.RoomID, session.QuestionEventID, fmt.Sprintf("⏰ Time's up! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
} else {
p.mu.Unlock()
}
}()
return nil
}
func (p *TriviaPlugin) handleAnswer(ctx MessageContext, session *activeSession) error {
answer := strings.TrimSpace(strings.ToLower(ctx.Body))
// Map letter answers to index
answerIndex := -1
switch answer {
case "a":
answerIndex = 0
case "b":
answerIndex = 1
case "c":
answerIndex = 2
case "d":
answerIndex = 3
case "true":
// Find "True" in answers
for i, a := range session.AllAnswers {
if strings.ToLower(a) == "true" {
answerIndex = i
break
}
}
case "false":
for i, a := range session.AllAnswers {
if strings.ToLower(a) == "false" {
answerIndex = i
break
}
}
default:
// Not a trivia answer, ignore
return nil
}
if answerIndex < 0 || answerIndex >= len(session.AllAnswers) {
return nil
}
elapsed := time.Since(session.StartedAt)
elapsedMs := elapsed.Milliseconds()
correct := answerIndex == session.CorrectIndex
// Remove session
p.mu.Lock()
current, ok := p.sessions[ctx.RoomID]
if !ok || current != session {
p.mu.Unlock()
return nil // Session already ended
}
delete(p.sessions, ctx.RoomID)
p.mu.Unlock()
if correct {
// Calculate score: 100 at 3s, scaling to 0 at 30s
score := calculateScore(elapsed)
// Update scores in DB
_, err := db.Get().Exec(
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
VALUES (?, ?, 1, 0, ?, ?)
ON CONFLICT(user_id, room_id) DO UPDATE SET
correct = correct + 1,
total_score = total_score + ?,
fastest_ms = CASE WHEN fastest_ms = 0 OR ? < fastest_ms THEN ? ELSE fastest_ms END`,
string(ctx.Sender), string(ctx.RoomID), score, elapsedMs,
score, elapsedMs, elapsedMs,
)
if err != nil {
slog.Error("trivia: update score", "err", err)
}
// Update session record (use subquery since SQLite doesn't support UPDATE...ORDER BY...LIMIT)
if _, err := db.Get().Exec(
`UPDATE trivia_sessions SET ended = 1, winner_id = ?, winner_time_ms = ?
WHERE id = (SELECT id FROM trivia_sessions WHERE room_id = ? AND ended = 0 ORDER BY started_at DESC LIMIT 1)`,
string(ctx.Sender), elapsedMs, string(ctx.RoomID),
); err != nil {
slog.Error("trivia: update session", "err", err)
}
return p.SendThread(ctx.RoomID, session.QuestionEventID,
fmt.Sprintf("✅ Correct! %s answered in %.2fs for %d points!", ctx.Sender, float64(elapsedMs)/1000.0, score))
}
// Wrong answer
_, err := db.Get().Exec(
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
VALUES (?, ?, 0, 1, 0, 0)
ON CONFLICT(user_id, room_id) DO UPDATE SET
wrong = wrong + 1`,
string(ctx.Sender), string(ctx.RoomID),
)
if err != nil {
slog.Error("trivia: update wrong score", "err", err)
}
letters := []string{"A", "B", "C", "D"}
ansLetter := ""
if session.CorrectIndex < len(letters) {
ansLetter = letters[session.CorrectIndex] + ". "
}
return p.SendThread(ctx.RoomID, session.QuestionEventID,
fmt.Sprintf("❌ Wrong! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
}
// calculateScore returns time-weighted points: 100 at 3s, scaling linearly to 0 at 30s.
func calculateScore(elapsed time.Duration) int {
secs := elapsed.Seconds()
if secs <= 3.0 {
return 100
}
if secs >= 30.0 {
return 0
}
// Linear interpolation: 100 at 3s -> 0 at 30s
score := int(100.0 * (30.0 - secs) / 27.0)
if score < 0 {
score = 0
}
return score
}