Files
gogobee/internal/plugin/stats.go
prosolis 4e412219f3 WIP: in-flight combat/expedition/flavor changes
Bundle of uncommitted working-tree edits across combat engine, expedition
cycle, flavor pools, and TwinBee/zone narration. Includes new files:
combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus
CHANGES_24H.md and REBALANCE_NOTES.md scratch notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:59:19 -07:00

556 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/util"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
var milestoneThresholds = []int{
1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
}
// StatsPlugin passively tracks message statistics and provides query commands.
type StatsPlugin struct {
Base
}
// NewStatsPlugin creates a new stats plugin.
func NewStatsPlugin(client *mautrix.Client) *StatsPlugin {
return &StatsPlugin{
Base: NewBase(client),
}
}
func (p *StatsPlugin) Name() string { return "stats" }
func (p *StatsPlugin) Version() string { return "1.1.0" }
func (p *StatsPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"},
{Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"},
{Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"},
{Name: "superstatsexplusalpha", Description: "Everything we know about you", Usage: "!superstatsexplusalpha [@user]", Category: "Leveling & Stats"},
}
}
func (p *StatsPlugin) Init() error { return nil }
func (p *StatsPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *StatsPlugin) OnMessage(ctx MessageContext) error {
// Handle commands first
if p.IsCommand(ctx.Body, "stats") {
return p.handleStats(ctx)
}
if p.IsCommand(ctx.Body, "rankings") {
return p.handleRankings(ctx)
}
if p.IsCommand(ctx.Body, "personality") {
return p.handlePersonality(ctx)
}
if p.IsCommand(ctx.Body, "superstatsexplusalpha") {
return p.handleSuperStats(ctx)
}
// Skip tracking for bot commands
if ctx.IsCommand {
return nil
}
// Passive: track message stats
return p.trackMessage(ctx)
}
func (p *StatsPlugin) trackMessage(ctx MessageContext) error {
stats := util.ParseMessage(ctx.Body)
d := db.Get()
now := time.Now().UTC()
// Determine time-of-day buckets
hour := now.Hour()
nightIncr := 0
morningIncr := 0
if hour >= 0 && hour < 6 {
nightIncr = 1
} else if hour >= 6 && hour < 12 {
morningIncr = 1
}
_, err := d.Exec(
`INSERT INTO user_stats (user_id, total_messages, total_words, total_chars, total_links,
total_images, total_questions, total_exclamations, total_emojis, night_messages, morning_messages, updated_at)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
total_messages = total_messages + 1,
total_words = total_words + ?,
total_chars = total_chars + ?,
total_links = total_links + ?,
total_images = total_images + ?,
total_questions = total_questions + ?,
total_exclamations = total_exclamations + ?,
total_emojis = total_emojis + ?,
night_messages = night_messages + ?,
morning_messages = morning_messages + ?,
updated_at = ?`,
string(ctx.Sender),
stats.Words, stats.Chars, stats.Links, stats.Images,
stats.Questions, stats.Exclamations, stats.Emojis,
nightIncr, morningIncr, now.Unix(),
// ON CONFLICT values
stats.Words, stats.Chars, stats.Links, stats.Images,
stats.Questions, stats.Exclamations, stats.Emojis,
nightIncr, morningIncr, now.Unix(),
)
if err != nil {
slog.Error("stats: track message", "err", err)
return nil // Don't fail the message pipeline
}
// Room milestone tracking
p.checkRoomMilestone(ctx)
return nil
}
func (p *StatsPlugin) checkRoomMilestone(ctx MessageContext) {
d := db.Get()
// Upsert room message count
_, err := d.Exec(
`INSERT INTO room_milestones (room_id, total_messages, last_milestone)
VALUES (?, 1, 0)
ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1`,
string(ctx.RoomID),
)
if err != nil {
slog.Error("stats: room milestone upsert", "err", err)
return
}
var totalMessages, lastMilestone int
err = d.QueryRow(
`SELECT total_messages, last_milestone FROM room_milestones WHERE room_id = ?`,
string(ctx.RoomID),
).Scan(&totalMessages, &lastMilestone)
if err != nil {
slog.Error("stats: room milestone read", "err", err)
return
}
// Check if we crossed a new milestone
for _, threshold := range milestoneThresholds {
if totalMessages >= threshold && lastMilestone < threshold {
_, err = d.Exec(
`UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`,
threshold, string(ctx.RoomID),
)
if err != nil {
slog.Error("stats: update milestone", "err", err)
return
}
msg := fmt.Sprintf("🎉 This room just hit %s messages! Keep the conversation going!", formatNumber(threshold))
if err := p.SendMessage(ctx.RoomID, msg); err != nil {
slog.Error("stats: milestone announcement", "err", err)
}
return // Only announce one milestone at a time
}
}
}
func (p *StatsPlugin) handleStats(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "stats")
if args != "" {
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
target = resolved
}
}
d := db.Get()
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
var totalQuestions, totalExclamations, totalEmojis, nightMsg, morningMsg int
err := d.QueryRow(
`SELECT total_messages, total_words, total_chars, total_links, total_images,
total_questions, total_exclamations, total_emojis, night_messages, morning_messages
FROM user_stats WHERE user_id = ?`, string(target),
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
&totalQuestions, &totalExclamations, &totalEmojis, &nightMsg, &morningMsg)
if err == sql.ErrNoRows {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No stats found for %s.", string(target)))
}
if err != nil {
slog.Error("stats: query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up stats.")
}
avgWords := 0
if totalMsg > 0 {
avgWords = totalWords / totalMsg
}
var taxPaid int
_ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, string(target)).Scan(&taxPaid)
msg := fmt.Sprintf(
"📊 Stats for %s\n"+
"Messages: %s | Words: %s | Chars: %s\n"+
"Links: %s | Images: %s | Emojis: %s\n"+
"Questions: %s | Exclamations: %s\n"+
"Night msgs (00-06): %s | Morning msgs (06-12): %s\n"+
"Avg words/msg: %d",
string(target),
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalChars),
formatNumber(totalLinks), formatNumber(totalImages), formatNumber(totalEmojis),
formatNumber(totalQuestions), formatNumber(totalExclamations),
formatNumber(nightMsg), formatNumber(morningMsg),
avgWords,
)
if taxPaid > 0 {
msg += fmt.Sprintf("\n🏛 Community tax paid: %s", fmtEuro(taxPaid))
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
args := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "rankings")))
column := "total_words"
label := "Words"
switch args {
case "links":
column = "total_links"
label = "Links"
case "questions":
column = "total_questions"
label = "Questions"
case "emojis":
column = "total_emojis"
label = "Emojis"
case "words", "":
column = "total_words"
label = "Words"
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
}
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
// Using fmt.Sprintf for column name is safe here since we control the value above
query := fmt.Sprintf(
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC`,
column, column, column,
)
rows, err := d.Query(query)
if err != nil {
slog.Error("stats: rankings query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load rankings.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📈 %s Rankings — Top 10\n\n", label))
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() && i < 10 {
var userID string
var val int
if err := rows.Scan(&userID, &val); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]
}
sb.WriteString(fmt.Sprintf("%s %s — %s %s\n", prefix, userID, formatNumber(val), strings.ToLower(label)))
i++
}
if i == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No %s data yet.", strings.ToLower(label)))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
archetypes := GetUserArchetypesLimited(string(ctx.Sender))
var totalMsg int
db.Get().QueryRow(`SELECT total_messages FROM user_stats WHERE user_id = ?`, string(ctx.Sender)).Scan(&totalMsg)
if totalMsg == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!")
}
msg := fmt.Sprintf("🧠 %s, your chat personality:\n\n%s\n\nBased on %s messages analyzed.",
string(ctx.Sender), FormatArchetypesFull(archetypes), formatNumber(totalMsg))
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "superstatsexplusalpha")
if args != "" {
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
target = resolved
}
}
d := db.Get()
uid := string(target)
displayName := p.DisplayName(target)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📋 **SUPER STATS EX+α — %s**\n", displayName))
sb.WriteString("═══════════════════════════════\n\n")
// ── Level & Economy ──
var xp, level int
var balance float64
_ = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level)
_ = d.QueryRow(`SELECT balance FROM euro_balances WHERE user_id = ?`, uid).Scan(&balance)
var achievementCount int
_ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount)
var taxPaid int
_ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, uid).Scan(&taxPaid)
taxLine := ""
if taxPaid > 0 {
taxLine = fmt.Sprintf(" · 🏛️ %s tax paid", fmtEuro(taxPaid))
}
sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements%s\n\n",
level, formatNumber(xp), balance, achievementCount, taxLine))
// ── Chat Stats ──
var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int
err := d.QueryRow(
`SELECT total_messages, total_words, total_links, total_emojis, total_questions
FROM user_stats WHERE user_id = ?`, uid,
).Scan(&totalMsg, &totalWords, &totalLinks, &totalEmojis, &totalQuestions)
if err == nil && totalMsg > 0 {
sb.WriteString(fmt.Sprintf("💬 **Chat:** %s msgs · %s words · %s links · %s emoji · %s questions\n",
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks),
formatNumber(totalEmojis), formatNumber(totalQuestions)))
// Personality (from cached archetypes)
archetypes := GetUserArchetypesLimited(uid)
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", FormatArchetypeNames(archetypes)))
}
// ── Sentiment ──
var positive, negative, neutral int
err = d.QueryRow(
`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
FROM sentiment_stats WHERE user_id = ?`, uid,
).Scan(&positive, &negative, &neutral)
if err == nil {
total := positive + negative + neutral
if total > 0 {
sb.WriteString(fmt.Sprintf(" Sentiment: +%d / -%d / ~%d", positive, negative, neutral))
pct := float64(positive) / float64(total) * 100
if pct >= 60 {
sb.WriteString(" 😊")
} else if float64(negative)/float64(total)*100 >= 40 {
sb.WriteString(" 😤")
}
sb.WriteString("\n")
}
}
// ── Potty Mouth ──
var mild, moderate, scorching int
err = d.QueryRow(
`SELECT COALESCE(mild,0), COALESCE(moderate,0), COALESCE(scorching,0)
FROM potty_mouth WHERE user_id = ?`, uid,
).Scan(&mild, &moderate, &scorching)
if err == nil && (mild+moderate+scorching) > 0 {
sb.WriteString(fmt.Sprintf(" Potty mouth: 🤬 %d mild · %d moderate · %d scorching\n", mild, moderate, scorching))
}
sb.WriteString("\n")
// ── Adventure ──
var miningLv, fishingLv, forageLv, miningXP, fishingXP, forageXP int
var alive bool
var streak, bestStreak int
err = d.QueryRow(
`SELECT mining_skill, fishing_skill, foraging_skill,
mining_xp, fishing_xp, foraging_xp,
alive, current_streak, best_streak
FROM player_meta WHERE user_id = ?`, uid,
).Scan(&miningLv, &fishingLv, &forageLv, &miningXP, &fishingXP, &forageXP,
&alive, &streak, &bestStreak)
if err == nil {
status := "Alive"
if !alive {
status = "💀 Dead"
}
sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status))
sb.WriteString(fmt.Sprintf(" Mining Lv.%d (%d XP) · Fishing Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n",
miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP))
if streak > 0 || bestStreak > 0 {
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak))
}
// Equipment score (use canonical scoring function)
equip, eqErr := loadAdvEquipment(id.UserID(uid))
if eqErr == nil && len(equip) > 0 {
sb.WriteString(fmt.Sprintf(" Equipment score: %.1f\n", advEquipmentScore(equip)))
}
sb.WriteString("\n")
}
// ── Game Records ──
sb.WriteString("🎮 **Games:**\n")
hasGames := false
// Holdem
var hPlayed int
var hWon, hLost, hBiggest int64
err = d.QueryRow(
`SELECT hands_played, total_won, total_lost, biggest_pot
FROM holdem_scores WHERE user_id = ?`, uid,
).Scan(&hPlayed, &hWon, &hLost, &hBiggest)
if err == nil && hPlayed > 0 {
net := hWon - hLost
sign := "+"
if net < 0 {
sign = ""
}
sb.WriteString(fmt.Sprintf(" 🃏 Holdem: %d hands · %s€%d net · biggest pot €%d\n",
hPlayed, sign, net, hBiggest))
hasGames = true
}
// Blackjack
var bjPlayed, bjWon int
var bjEarned float64
err = d.QueryRow(
`SELECT games_played, games_won, total_earned
FROM blackjack_scores WHERE user_id = ?`, uid,
).Scan(&bjPlayed, &bjWon, &bjEarned)
if err == nil && bjPlayed > 0 {
sb.WriteString(fmt.Sprintf(" 🂡 Blackjack: %d/%d W/L · €%.0f earned\n",
bjWon, bjPlayed-bjWon, bjEarned))
hasGames = true
}
// Hangman
var hmPlayed, hmWon int
var hmEarned float64
err = d.QueryRow(
`SELECT games_played, games_won, total_earned
FROM hangman_scores WHERE user_id = ?`, uid,
).Scan(&hmPlayed, &hmWon, &hmEarned)
if err == nil && hmPlayed > 0 {
sb.WriteString(fmt.Sprintf(" 📝 Hangman: %d/%d W/L · €%.0f earned\n",
hmWon, hmPlayed-hmWon, hmEarned))
hasGames = true
}
// Wordle
var wPlayed, wSolved, wGuesses, wEarned int
err = d.QueryRow(
`SELECT puzzles_played, puzzles_solved, total_guesses, total_earned
FROM wordle_stats WHERE user_id = ?`, uid,
).Scan(&wPlayed, &wSolved, &wGuesses, &wEarned)
if err == nil && wPlayed > 0 {
avg := float64(wGuesses) / float64(wPlayed)
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses · €%d earned\n",
wSolved, wPlayed, avg, wEarned))
hasGames = true
}
// Trivia
var tCorrect, tWrong int
var tFastest int64
err = d.QueryRow(
`SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(MIN(fastest_ms),0)
FROM trivia_scores WHERE user_id = ?`, uid,
).Scan(&tCorrect, &tWrong, &tFastest)
if (tCorrect + tWrong) > 0 {
fastStr := ""
if tFastest > 0 {
fastStr = fmt.Sprintf(" · fastest: %.1fs", float64(tFastest)/1000)
}
pct := float64(tCorrect) / float64(tCorrect+tWrong) * 100
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct (%.0f%%)%s\n",
tCorrect, tCorrect+tWrong, pct, fastStr))
hasGames = true
}
// UNO (single + multi combined)
var unoSingleWins, unoSingleTotal int
_ = d.QueryRow(
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0)
FROM uno_games WHERE player_id = ?`, uid,
).Scan(&unoSingleTotal, &unoSingleWins)
var unoSingleEarned float64
_ = d.QueryRow(
`SELECT COALESCE(SUM(CASE
WHEN result IN ('player_win','sudden_death_player') THEN (pot_before - pot_after) - wager
ELSE -wager END), 0)
FROM uno_games WHERE player_id = ?`, uid,
).Scan(&unoSingleEarned)
var unoMultiWins, unoMultiTotal int
_ = d.QueryRow(
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0)
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
).Scan(&unoMultiTotal, &unoMultiWins)
var unoMultiEarned float64
_ = d.QueryRow(
`SELECT COALESCE(SUM(CASE WHEN winner_id = ? THEN pot_total - ante ELSE -ante END), 0)
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
).Scan(&unoMultiEarned)
unoTotal := unoSingleTotal + unoMultiTotal
unoWins := unoSingleWins + unoMultiWins
unoEarned := unoSingleEarned + unoMultiEarned
if unoTotal > 0 {
sign := "+"
if unoEarned < 0 {
sign = ""
}
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L · %s€%.0f net\n",
unoWins, unoTotal-unoWins, sign, unoEarned))
hasGames = true
}
if !hasGames {
sb.WriteString(" No game records yet.\n")
}
// ── Commands ──
var cmdCount int
_ = d.QueryRow(`SELECT COALESCE(SUM(count), 0) FROM command_usage WHERE user_id = ?`, uid).Scan(&cmdCount)
if cmdCount > 0 {
sb.WriteString(fmt.Sprintf("\n⌨ %s commands used\n", formatNumber(cmdCount)))
}
// ── Account Age ──
var createdAt time.Time
err = d.QueryRow(`SELECT created_at FROM users WHERE user_id = ?`, uid).Scan(&createdAt)
if err == nil {
days := int(time.Since(createdAt).Hours() / 24)
sb.WriteString(fmt.Sprintf("📅 Member for %d days\n", days))
}
return p.SendMessage(ctx.RoomID, sb.String())
}