Files
gogobee/internal/plugin/adventure_arena.go
prosolis 0d3485c7c2 Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements
- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 21:26:19 -07:00

839 lines
26 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Arena Run State ─────────────────────────────────────────────────────────
type ArenaRun struct {
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
}
// ── Command Dispatch ────────────────────────────────────────────────────────
func (p *AdventurePlugin) dispatchArenaCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "arena"))
lower := strings.ToLower(args)
switch {
case args == "" || lower == "menu":
return p.handleArenaMenu(ctx)
case strings.HasPrefix(lower, "tier "):
return p.handleArenaTier(ctx, strings.TrimSpace(args[5:]))
case lower == "fight" || lower == "f":
return p.handleArenaFight(ctx)
case lower == "descend":
return p.handleArenaDescend(ctx)
case lower == "cashout":
return p.handleArenaCashout(ctx)
case lower == "cancel":
return p.handleArenaCancel(ctx)
case lower == "status":
return p.handleArenaStatus(ctx)
case lower == "stats":
return p.handleArenaStats(ctx)
case lower == "leaderboard" || lower == "lb":
return p.handleArenaLeaderboard(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, arenaHelpText)
}
return p.SendDM(ctx.Sender, "Unknown arena command. Type `!arena help` for available commands.")
}
const arenaHelpText = `**Arena Commands**
` + "`!arena`" + ` — Show tier menu and eligible tiers
` + "`!arena tier <1-5>`" + ` — Preview a tier (requires confirmation)
` + "`!arena fight`" + ` — Confirm entry or fight current round
` + "`!arena cancel`" + ` — Cancel pending tier entry
` + "`!arena descend`" + ` — Descend to next tier after clearing (earnings stay at risk)
` + "`!arena cashout`" + ` — Take your earnings and leave
` + "`!arena status`" + ` — Current run state
` + "`!arena stats`" + ` — Your personal arena stats
` + "`!arena leaderboard`" + ` — Top arena players
` + "`!arena help`" + ` — This message
The Arena is independent of your daily adventure action. You can run both on the same day. Death in the arena locks you out of both arena and adventure until midnight UTC.`
// ── Handlers ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first — the Arena is no place for the unregistered.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Clear any pending tier selection when viewing menu
p.arenaPending.Delete(string(ctx.Sender))
// Check for active run
run, err := loadActiveArenaRun(ctx.Sender)
if err == nil && run != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run))
}
stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaTierMenu(char, stats))
}
func (p *AdventurePlugin) handleArenaTier(ctx MessageContext, arg string) error {
tierNum, err := strconv.Atoi(arg)
if err != nil || tierNum < 1 || tierNum > 5 {
return p.SendDM(ctx.Sender, "Invalid tier. Use `!arena tier <1-5>`.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Check active run
existing, _ := loadActiveArenaRun(ctx.Sender)
if existing != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing))
}
tier := arenaGetTier(tierNum)
if tier == nil {
return p.SendDM(ctx.Sender, "Invalid tier.")
}
// Level gate
if char.CombatLevel < tier.MinLevel {
return p.SendDM(ctx.Sender, renderArenaLevelGate(tier, char.CombatLevel))
}
// Store pending tier selection — run is NOT created yet
p.arenaPending.Store(string(ctx.Sender), tierNum)
monster := arenaGetMonster(tierNum, 1)
text := renderArenaTierConfirm(tier, monster)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Check for pending tier confirmation first
if val, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok {
tierNum := val.(int)
return p.confirmAndStartArenaRun(ctx, tierNum)
}
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "You don't have an active arena run. Start one with `!arena tier <1-5>`.")
}
if run.Status != "active" {
if run.Status == "awaiting" {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run))
}
return p.SendDM(ctx.Sender, "Your arena run is not in a fightable state.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
equip, _ := loadAdvEquipment(ctx.Sender)
tier := arenaGetTier(run.Tier)
monster := arenaGetMonster(run.Tier, run.Round)
if tier == nil || monster == nil {
return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.")
}
// Resolve combat
deathChance := arenaDeathChance(monster, char, equip)
roll := rand.Float64()
died := roll < deathChance
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
}
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum int) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Re-check active run (could have changed since tier selection)
existing, _ := loadActiveArenaRun(ctx.Sender)
if existing != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing))
}
tier := arenaGetTier(tierNum)
if tier == nil || char.CombatLevel < tier.MinLevel {
return p.SendDM(ctx.Sender, "You are no longer eligible for this tier.")
}
// NOW create the run
run := &ArenaRun{
UserID: ctx.Sender,
RoomID: ctx.RoomID,
StartTier: tierNum,
Tier: tierNum,
Round: 1,
Status: "active",
Earnings: 0,
StartedAt: time.Now().UTC(),
}
if err := createArenaRun(run); err != nil {
slog.Error("arena: failed to create run", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Failed to start arena run.")
}
// Resolve round 1 immediately
equip, _ := loadAdvEquipment(ctx.Sender)
monster := arenaGetMonster(tierNum, 1)
deathChance := arenaDeathChance(monster, char, equip)
roll := rand.Float64()
died := roll < deathChance
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
}
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
if _, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok {
return p.SendDM(ctx.Sender, "Tier entry cancelled. The Arena will wait.")
}
return p.SendDM(ctx.Sender, "Nothing to cancel.")
}
func (p *AdventurePlugin) handleArenaDescend(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil || run.Status != "awaiting" {
return p.SendDM(ctx.Sender, "You don't have a pending tier decision.")
}
if run.Tier >= 5 {
return p.SendDM(ctx.Sender, "There is nothing deeper. You've reached the bottom.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
nextTier := arenaGetTier(run.Tier + 1)
if nextTier == nil {
return p.SendDM(ctx.Sender, "Invalid next tier.")
}
// Level gate for next tier
if char.CombatLevel < nextTier.MinLevel {
return p.SendDM(ctx.Sender, renderArenaLevelGate(nextTier, char.CombatLevel))
}
// Clear auto-cashout deadline
p.arenaDeadlines.Delete(string(ctx.Sender))
// Update run: advance to next tier
run.Tier = nextTier.Number
run.Round = 1
run.Status = "active"
if err := saveArenaRun(run); err != nil {
return p.SendDM(ctx.Sender, "Failed to update arena run.")
}
// Grant descend achievement
if p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_descend")
}
monster := arenaGetMonster(nextTier.Number, 1)
text := renderArenaRoundStart(nextTier, 1, monster, run)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleArenaCashout(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil || run.Status != "awaiting" {
return p.SendDM(ctx.Sender, "You don't have a pending tier decision.")
}
return p.arenaCompleteCashout(ctx.Sender, run, false)
}
func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena tier <1-5>`.")
}
return p.SendDM(ctx.Sender, renderArenaStatus(run, char))
}
func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first.")
}
stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaPersonalStats(char, stats))
}
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
entries, err := loadArenaLeaderboard()
if err != nil {
slog.Error("arena: failed to load leaderboard", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
}
// ── Combat Resolution ───────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
// Calculate reward
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
run.Earnings += reward
run.RoundsSurvived++
run.LastMonster = monster.Name
// Award battle XP
char.CombatXP += tier.BattleXP
leveled, newLevel := checkAdvLevelUp(char, "combat")
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
}
// Build survival message
text := renderArenaSurvival(tier, run.Round, monster, reward, tier.BattleXP, run.Earnings)
if leveled {
text += fmt.Sprintf("\n🎉 **Combat Level %d!**", newLevel)
}
// Achievement: first blood
if run.RoundsSurvived == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_first_blood")
}
// Achievement: Omega Mk. Zero defeated (T5R1 survival)
if run.Tier == 5 && run.Round == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_omega")
}
// Check if tier is complete (4 rounds)
if run.Round >= 4 {
// Add completion bonus
run.Earnings += tier.CompletionBonus
run.Round = 4 // keep at 4 for clarity
if run.Tier >= 5 {
// Tier 5 complete — full payout, run ends
return p.arenaCompleteTier5(ctx, run, char, tier, text)
}
// Tier complete, awaiting decision
run.Status = "awaiting"
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after tier complete", "user", ctx.Sender, "err", err)
}
// Set auto-cashout deadline
deadline := time.Now().UTC().Add(10 * time.Minute)
p.arenaDeadlines.Store(string(ctx.Sender), deadline)
text += "\n\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings)
// Grant tier achievement
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
return p.SendDM(ctx.Sender, text)
}
// Advance to next round
run.Round++
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after round", "user", ctx.Sender, "err", err)
}
// Reveal next monster
nextMonster := arenaGetMonster(run.Tier, run.Round)
if nextMonster != nil {
text += fmt.Sprintf("\n\n─────────────────────────────\n\n")
text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
text += "`!arena fight` — Face this opponent"
}
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
lostEarnings := run.Earnings
run.LastMonster = monster.Name
// Kill the character (locked out until next midnight UTC)
char.Alive = false
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.ArenaLosses++
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
}
// End the run
run.Status = "dead"
run.Earnings = 0
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run", "user", ctx.Sender, "err", err)
}
// Insert history
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
// Update stats
upsertArenaStats(run.UserID, 0, true, run.Tier)
// Achievement: death in T5
if run.Tier == 5 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5")
}
// Pick death message
deathMsg := arenaPickDeathMessage(monster, run.Tier, run.Round)
text := renderArenaDeath(tier, run.Round, monster, lostEarnings, deathMsg)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, prefixText string) error {
// Credit earnings
p.euro.Credit(run.UserID, float64(run.Earnings), "arena_tier5_complete")
char.ArenaWins++
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after T5 complete", "user", ctx.Sender, "err", err)
}
// End run
now := time.Now().UTC()
run.Status = "completed"
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run after T5", "user", ctx.Sender, "err", err)
}
// History and stats
insertArenaHistory(run.UserID, run.StartTier, 5, run.RoundsSurvived, run.Earnings, "completed", "That Which Has Always Been")
upsertArenaStats(run.UserID, run.Earnings, false, 5)
// Grant achievements
p.grantArenaTierAchievement(ctx.Sender, 5)
if run.StartTier == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_full_run")
}
// Room announcement
gr := gamesRoom()
if gr != "" {
announce := fmt.Sprintf("🏆 **%s has conquered the Arena.** Tier 5 cleared. €%d earned. That Which Has Always Been has fallen.",
char.DisplayName, run.Earnings)
p.SendMessage(id.RoomID(gr), announce)
}
text := prefixText + "\n\n" + renderArenaTier5Complete(run.Earnings, run.StartTier)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) arenaCompleteCashout(userID id.UserID, run *ArenaRun, isAuto bool) error {
// Clear deadline
p.arenaDeadlines.Delete(string(userID))
// Credit earnings
p.euro.Credit(userID, float64(run.Earnings), "arena_cashout")
// Load char to update wins
char, err := loadAdvCharacter(userID)
if err == nil {
char.ArenaWins++
saveAdvCharacter(char)
}
// End run
now := time.Now().UTC()
run.Status = "cashed_out"
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run on cashout", "user", userID, "err", err)
}
// History and stats
insertArenaHistory(userID, run.StartTier, run.Tier, run.RoundsSurvived, run.Earnings, "cashed_out", run.LastMonster)
upsertArenaStats(userID, run.Earnings, false, run.Tier)
// Achievement: big cashout
if run.Earnings >= 10000 && p.achievements != nil {
p.achievements.GrantAchievement(userID, "arena_cashout_big")
}
if isAuto {
return p.SendDM(userID, renderArenaAutoCashout(run.Earnings))
}
return p.SendDM(userID, renderArenaCashout(run.Earnings, run.Tier))
}
// ── Combat Math ─────────────────────────────────────────────────────────────
func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment) float64 {
baseDeath := monster.BaseLethality
levelMod := float64(monster.ThreatLevel-char.CombatLevel) * 0.015
skillMod := math.Max(0, 0.25-float64(char.CombatLevel)*0.008)
// Average equipment tier → up to 15% reduction at max gear (tier 5 avg = 0.15)
var totalTier float64
count := 0
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
totalTier += float64(eq.Tier)
count++
}
}
avgTier := 0.0
if count > 0 {
avgTier = totalTier / float64(count)
}
equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5
deathChance := baseDeath + levelMod - equipMod + skillMod
return math.Max(0.01, math.Min(0.98, deathChance))
}
func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 {
base := tier.BasePayout * int64(round)
skillBonus := int64(float64(battleSkill) * tier.SkillMultiplier)
return base + skillBonus
}
// ── Achievement Helpers ─────────────────────────────────────────────────────
func (p *AdventurePlugin) grantArenaTierAchievement(userID id.UserID, tier int) {
if p.achievements == nil {
return
}
ids := map[int]string{
1: "arena_tier1",
2: "arena_tier2",
3: "arena_tier3",
4: "arena_tier4",
5: "arena_tier5",
}
if achID, ok := ids[tier]; ok {
p.achievements.GrantAchievement(userID, achID)
}
}
// ── Death Message Selection ─────────────────────────────────────────────────
func arenaPickDeathMessage(monster *ArenaMonster, tier, round int) string {
// 50% chance of generic, 50% monster-specific
if rand.IntN(2) == 0 {
return arenaDeathMessages[rand.IntN(len(arenaDeathMessages))]
}
template := arenaMonsterDeathMessages[rand.IntN(len(arenaMonsterDeathMessages))]
r := strings.NewReplacer(
"{monster}", monster.Name,
"{tier}", strconv.Itoa(tier),
"{round}", strconv.Itoa(round),
)
return r.Replace(template)
}
// ── Auto-Cashout Ticker ─────────────────────────────────────────────────────
func (p *AdventurePlugin) arenaAutoCashoutTicker() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
p.arenaDeadlines.Range(func(key, value any) bool {
userIDStr := key.(string)
deadline := value.(time.Time)
if now.After(deadline) {
p.arenaDeadlines.Delete(userIDStr)
go p.autoCollectArena(id.UserID(userIDStr))
}
return true
})
}
}
func (p *AdventurePlugin) autoCollectArena(userID id.UserID) {
userMu := p.advUserLock(userID)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(userID)
if err != nil || run == nil || run.Status != "awaiting" {
return
}
if err := p.arenaCompleteCashout(userID, run, true); err != nil {
slog.Error("arena: auto-cashout failed", "user", userID, "err", err)
}
}
// arenaCleanupStaleRuns auto-cashes out any runs left in 'awaiting' status
// (e.g. from a bot restart). Called during Init().
func (p *AdventurePlugin) arenaCleanupStaleRuns() {
runs, err := loadAwaitingArenaRuns()
if err != nil {
slog.Error("arena: failed to load stale runs", "err", err)
return
}
for _, run := range runs {
run := run
slog.Info("arena: auto-cashing out stale awaiting run", "user", run.UserID, "earnings", run.Earnings)
if err := p.arenaCompleteCashout(run.UserID, &run, true); err != nil {
slog.Error("arena: stale run cashout failed", "user", run.UserID, "err", err)
}
}
}
// ── DB CRUD ─────────────────────────────────────────────────────────────────
func loadActiveArenaRun(userID id.UserID) (*ArenaRun, error) {
d := db.Get()
run := &ArenaRun{}
var startedAt int64
var endedAt *int64
err := d.QueryRow(`
SELECT id, user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at, ended_at
FROM arena_runs
WHERE user_id = ? AND status IN ('active', 'awaiting')
ORDER BY id DESC LIMIT 1`, string(userID)).Scan(
&run.ID, &run.UserID, &run.RoomID, &run.StartTier, &run.Tier, &run.Round,
&run.Status, &run.Earnings, &run.RoundsSurvived, &run.LastMonster,
&startedAt, &endedAt,
)
if err != nil {
return nil, err
}
run.StartedAt = time.Unix(startedAt, 0).UTC()
if endedAt != nil {
t := time.Unix(*endedAt, 0).UTC()
run.EndedAt = &t
}
return run, nil
}
func loadAwaitingArenaRuns() ([]ArenaRun, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at, ended_at
FROM arena_runs WHERE status = 'awaiting'`)
if err != nil {
return nil, err
}
defer rows.Close()
var runs []ArenaRun
for rows.Next() {
var r ArenaRun
var startedAt int64
var endedAt *int64
if err := rows.Scan(
&r.ID, &r.UserID, &r.RoomID, &r.StartTier, &r.Tier, &r.Round,
&r.Status, &r.Earnings, &r.RoundsSurvived, &r.LastMonster,
&startedAt, &endedAt,
); err != nil {
return nil, err
}
r.StartedAt = time.Unix(startedAt, 0).UTC()
if endedAt != nil {
t := time.Unix(*endedAt, 0).UTC()
r.EndedAt = &t
}
runs = append(runs, r)
}
return runs, rows.Err()
}
func createArenaRun(run *ArenaRun) error {
d := db.Get()
result, err := d.Exec(`
INSERT INTO arena_runs (user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
string(run.UserID), string(run.RoomID), run.StartTier, run.Tier, run.Round,
run.Status, run.Earnings, run.RoundsSurvived, run.LastMonster,
run.StartedAt.Unix(),
)
if err != nil {
return err
}
run.ID, _ = result.LastInsertId()
return nil
}
func saveArenaRun(run *ArenaRun) error {
d := db.Get()
var endedAt interface{}
if run.EndedAt != nil {
endedAt = run.EndedAt.Unix()
}
_, err := d.Exec(`
UPDATE arena_runs SET
tier = ?, round = ?, status = ?, earnings = ?,
rounds_survived = ?, last_monster = ?, ended_at = ?
WHERE id = ?`,
run.Tier, run.Round, run.Status, run.Earnings,
run.RoundsSurvived, run.LastMonster, endedAt,
run.ID,
)
return err
}
func insertArenaHistory(userID id.UserID, startTier, tier, roundsSurvived int, earnings int64, outcome, monsterName string) {
db.Exec("arena: insert history",
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(userID), startTier, tier, roundsSurvived, earnings, outcome, monsterName, time.Now().Unix(),
)
}
func upsertArenaStats(userID id.UserID, earnings int64, died bool, highestTier int) {
d := db.Get()
now := time.Now().Unix()
deathInc := 0
if died {
deathInc = 1
}
t5Inc := 0
if highestTier == 5 && !died {
t5Inc = 1
}
_, err := d.Exec(`
INSERT INTO arena_stats (user_id, total_runs, total_earnings, total_deaths, highest_tier, tier5_completions, updated_at)
VALUES (?, 1, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
total_runs = total_runs + 1,
total_earnings = total_earnings + ?,
total_deaths = total_deaths + ?,
highest_tier = MAX(highest_tier, ?),
tier5_completions = tier5_completions + ?,
updated_at = ?`,
string(userID), earnings, deathInc, highestTier, t5Inc, now,
earnings, deathInc, highestTier, t5Inc, now,
)
if err != nil {
slog.Error("arena: failed to upsert stats", "user", userID, "err", err)
}
}
func loadArenaLeaderboard() ([]ArenaLeaderboardEntry, error) {
d := db.Get()
rows, err := d.Query(`
SELECT s.user_id, COALESCE(c.display_name, s.user_id),
s.total_earnings, s.highest_tier, s.tier5_completions,
s.total_runs, s.total_deaths
FROM arena_stats s
LEFT JOIN adventure_characters c ON c.user_id = s.user_id
ORDER BY s.total_earnings DESC
LIMIT 10`)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ArenaLeaderboardEntry
for rows.Next() {
var e ArenaLeaderboardEntry
var uid string
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
d := db.Get()
stats := &ArenaPersonalStats{}
err := d.QueryRow(`
SELECT total_runs, total_earnings, total_deaths, highest_tier, tier5_completions
FROM arena_stats WHERE user_id = ?`, string(userID)).Scan(
&stats.TotalRuns, &stats.TotalEarnings, &stats.TotalDeaths,
&stats.HighestTier, &stats.Tier5Completions,
)
if err != nil {
return nil
}
return stats
}