Files
gogobee/internal/plugin/adventure_arena_render.go
prosolis e459b6e78d Full codebase audit: 21 security/robustness fixes, 328 tests across 4 packages
Security & economy:
- Credit()/Debit() reject non-positive amounts (closes infinite-money exploit)
- Lottery ticket purchase uses in-transaction count check (closes TOCTOU race)
- Arena bail channel ownership prevents double-close panic
- Entry.ID validated before exec.Command in fetch-esteemed
- Internal errors no longer leaked to users (forex, esteemed)

Robustness:
- safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins
- db.Close() added and called on shutdown
- Miniflux mutex pattern fixed (snapshot-under-lock)
- Silently ignored DB/JSON errors now logged (lottery_db)

Performance:
- Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id)

UX:
- Empty !buy args shows usage instead of "No item matching ''"
- "Failed to load character" now suggests !adventure

Test coverage:
- internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing)
- internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey)
- internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks)
- internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow)
- internal/db/db_test.go (4 tests: Init, Close, schema indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:04:40 -07:00

258 lines
10 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 (
"fmt"
"strings"
"time"
)
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
func renderArenaStreakEntry(char *AdventureCharacter, stats *ArenaPersonalStats, tier *ArenaTier, firstMonster *ArenaMonster) string {
var b strings.Builder
b.WriteString("⚔️ **THE ARENA**\n\n")
b.WriteString("The Arena is a streak. You start at Tier 1 and fight your way down.\n")
b.WriteString("After each tier, you have 30 seconds to bail or you auto-advance.\n")
b.WriteString("Death forfeits all accumulated rewards.\n\n")
b.WriteString(fmt.Sprintf("Combat Level: %d\n\n", char.CombatLevel))
for i := range arenaTiers {
t := &arenaTiers[i]
eligible := char.CombatLevel >= t.MinLevel
icon := "🔒"
if eligible {
icon = "⬚"
}
if stats != nil && stats.HighestTier >= t.Number {
icon = "✅"
}
mult := arenaStreakEuroMultiplier[t.Number]
b.WriteString(fmt.Sprintf("%s **Tier %d — %s** (Lv.%d+) — %.1f× euros\n", icon, t.Number, t.Name, t.MinLevel, mult))
}
if stats != nil && stats.TotalRuns > 0 {
b.WriteString(fmt.Sprintf("\nRuns: %d | Deaths: %d | Earned: €%d\n",
stats.TotalRuns, stats.TotalDeaths, stats.TotalEarnings))
}
b.WriteString("\n⛑ _Today's helmets are provided by Brim & Battle — celebrated makers of baseball hats — and proud sponsors of the Arena. ")
b.WriteString("Their new VeriFort line of field-evaluated combat headgear represents an exciting expansion beyond their area of expertise. ")
b.WriteString("Winners may receive a complimentary sample. Brim & Battle thanks you for your participation in their ongoing verification process._\n\n")
b.WriteString(fmt.Sprintf("**Round 1 opponent: %s**\n", firstMonster.Name))
b.WriteString(fmt.Sprintf("_%s_\n\n", firstMonster.Flavor))
b.WriteString("`!arena fight` — Enter and fight Round 1\n")
b.WriteString("`!arena cancel` — Back out")
return b.String()
}
// ── Round Start (Monster Reveal) ────────────────────────────────────────────
func renderArenaRoundStart(tier *ArenaTier, round int, monster *ArenaMonster, run *ArenaRun) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **Tier %d — %s | Round %d/4**\n\n", tier.Number, tier.Name, round))
b.WriteString(fmt.Sprintf("**%s**\n", monster.Name))
b.WriteString(fmt.Sprintf("_%s_\n\n", monster.Flavor))
sessionTotal := run.Earnings + run.TierEarnings
if sessionTotal > 0 {
b.WriteString(fmt.Sprintf("Session earnings: €%d (at risk)\n\n", sessionTotal))
}
b.WriteString("`!arena fight` — Face this opponent\n")
return b.String()
}
// renderArenaSurvival removed — survival text is built by renderArenaCombatLog + inline formatting.
// ── Tier Complete (Transition Prompt) ───────────────────────────────────────
// renderArenaTierComplete is no longer used — tier complete text is built inline
// in resolveArenaSurvival and the countdown DM handles the opt-out flow.
// renderArenaTier5Complete removed — T5 completion is part of arenaCompleteSession payout summary.
// ── Death ────────────────────────────────────────────────────────────────────
func renderArenaDeath(tier *ArenaTier, round int, monster *ArenaMonster, lostEarnings int64, deathMsg string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("💀 **DEAD** — Tier %d, Round %d\n\n", tier.Number, round))
b.WriteString(fmt.Sprintf("_%s_\n\n", deathMsg))
if lostEarnings > 0 {
b.WriteString(fmt.Sprintf("Forfeited earnings: €%d\n", lostEarnings))
}
b.WriteString("You are dead until midnight UTC. Both arena and daily adventure are blocked until you respawn.")
return b.String()
}
// renderArenaCashout and renderArenaAutoCashout removed — payout handled by arenaCompleteSession.
// ── Status ──────────────────────────────────────────────────────────────────
func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
tier := arenaGetTier(run.Tier)
if tier == nil {
return "No active arena run."
}
var b strings.Builder
b.WriteString("⚔️ **Arena Streak Status**\n\n")
b.WriteString(fmt.Sprintf("Tier: %d — %s\n", tier.Number, tier.Name))
sessionTotal := run.Earnings + run.TierEarnings
switch run.Status {
case "active":
monster := arenaGetMonster(run.Tier, run.Round)
b.WriteString(fmt.Sprintf("Round: %d/4\n", run.Round))
if monster != nil {
b.WriteString(fmt.Sprintf("Opponent: %s\n", monster.Name))
}
b.WriteString(fmt.Sprintf("Session total: €%d (at risk)\n", sessionTotal))
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
b.WriteString("\n`!arena fight` to continue")
case "awaiting":
b.WriteString(fmt.Sprintf("Tier %d cleared — advancing shortly\n", run.Tier))
b.WriteString(fmt.Sprintf("Session total: €%d (at risk)\n", sessionTotal))
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
b.WriteString("\n`!bail` to collect your rewards")
}
return b.String()
}
// ── Leaderboard ─────────────────────────────────────────────────────────────
type ArenaLeaderboardEntry struct {
DisplayName string
TotalEarnings int64
HighestTier int
Tier5Completions int
TotalRuns int
TotalDeaths int
}
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
if len(entries) == 0 {
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
}
var b strings.Builder
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
medals := []string{"🥇", "🥈", "🥉"}
for i, e := range entries {
prefix := fmt.Sprintf("%d.", i+1)
if i < 3 {
prefix = medals[i]
}
tierLabel := fmt.Sprintf("T%d", e.HighestTier)
if e.Tier5Completions > 0 {
tierLabel = fmt.Sprintf("T5×%d", e.Tier5Completions)
}
b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
}
return b.String()
}
// ── Personal Stats ──────────────────────────────────────────────────────────
type ArenaPersonalStats struct {
TotalRuns int
TotalEarnings int64
TotalDeaths int
HighestTier int
Tier5Completions int
}
func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStats) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", char.DisplayName))
if stats == nil || stats.TotalRuns == 0 {
b.WriteString("No arena runs yet. Type `!arena` to begin.")
return b.String()
}
b.WriteString(fmt.Sprintf("Total runs: %d\n", stats.TotalRuns))
b.WriteString(fmt.Sprintf("Total earnings: €%d\n", stats.TotalEarnings))
b.WriteString(fmt.Sprintf("Total deaths: %d\n", stats.TotalDeaths))
b.WriteString(fmt.Sprintf("Highest tier cleared: %d\n", stats.HighestTier))
if stats.TotalRuns > 0 {
survivalRate := float64(stats.TotalRuns-stats.TotalDeaths) / float64(stats.TotalRuns) * 100
b.WriteString(fmt.Sprintf("Survival rate: %.0f%%\n", survivalRate))
}
if stats.Tier5Completions > 0 {
b.WriteString(fmt.Sprintf("Tier 5 completions: %d\n", stats.Tier5Completions))
}
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", char.ArenaWins, char.ArenaLosses))
return b.String()
}
// ── Level Gate Message ──────────────────────────────────────────────────────
func renderArenaLevelGate(tier *ArenaTier, playerLevel int) string {
return fmt.Sprintf(
"⚔️ Tier %d — %s requires Combat Level %d. You are Level %d. "+
"The Arena does not negotiate.",
tier.Number, tier.Name, tier.MinLevel, playerLevel)
}
// ── Helmet Drop ────────────────────────────────────────────────────────────
func renderArenaHelmetDrop(gear *ArenaGearSet) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⛑️ **Equipment Drop: %s**\n\n", gear.HelmetName))
b.WriteString(fmt.Sprintf("_%s_\n\n", gear.Description))
b.WriteString(fmt.Sprintf("Tier %d Arena gear — 1.5x effectiveness. ", gear.Tier))
switch gear.SetKey {
case "bloodied":
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates.")
case "ironclad":
b.WriteString("Set bonus: **Battle-Hardened** — +5% XP gain from all activities.")
case "tempered":
b.WriteString("Set bonus: **Seasoned** — Equipment condition degrades 25% slower across all slots.")
case "champions":
b.WriteString("Set bonus: **Commanding Presence** — +10% to equipment score in all probability calculations.")
case "sovereign":
b.WriteString("Set bonus: **Death's Reprieve** — Once per 7 days, survive a lethal outcome instead of dying.")
}
b.WriteString("\n\nEquipped automatically.")
return b.String()
}
// ── Death's Reprieve Announcement ──────────────────────────────────────────
func renderArenaDeathReprieve(playerName, location string, nextWindow time.Time) string {
return fmt.Sprintf("⚔️ **%s**'s Sovereign gear activated **Death's Reprieve**. "+
"They should be dead. They are not. %s will have to try harder. Next window: %s.",
playerName, location, nextWindow.Format("2006-01-02 15:04 UTC"))
}
// ── Already In Run Message ──────────────────────────────────────────────────
func renderArenaAlreadyInRun(run *ArenaRun) string {
switch run.Status {
case "awaiting":
return fmt.Sprintf(
"Tier %d cleared, €%d accumulated. Waiting for next tier or `!bail` to collect.\n",
run.Tier, run.Earnings)
default:
return fmt.Sprintf(
"You're in an arena streak. Tier %d, Round %d.\n\n"+
"`!arena fight` to continue.",
run.Tier, run.Round)
}
}