Files
gogobee/internal/plugin/adventure_arena_test.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

1117 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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 (
"math"
"strings"
"testing"
"time"
)
// ── Monster Data Tests ──────────────────────────────────────────────────────
func TestArenaTierData(t *testing.T) {
if len(arenaTiers) != 5 {
t.Fatalf("expected 5 arena tiers, got %d", len(arenaTiers))
}
for i, tier := range arenaTiers {
if tier.Number != i+1 {
t.Errorf("tier %d: Number = %d, want %d", i, tier.Number, i+1)
}
if tier.Name == "" {
t.Errorf("tier %d: empty name", tier.Number)
}
if tier.MinLevel <= 0 {
t.Errorf("tier %d: MinLevel %d <= 0", tier.Number, tier.MinLevel)
}
if tier.BasePayout <= 0 {
t.Errorf("tier %d: BasePayout %d <= 0", tier.Number, tier.BasePayout)
}
if tier.SkillMultiplier <= 0 {
t.Errorf("tier %d: SkillMultiplier %f <= 0", tier.Number, tier.SkillMultiplier)
}
if tier.CompletionBonus <= 0 {
t.Errorf("tier %d: CompletionBonus %d <= 0", tier.Number, tier.CompletionBonus)
}
if tier.BattleXP <= 0 {
t.Errorf("tier %d: BattleXP %d <= 0", tier.Number, tier.BattleXP)
}
for j, m := range tier.Monsters {
if m.Name == "" {
t.Errorf("tier %d round %d: empty monster name", tier.Number, j+1)
}
if m.Flavor == "" {
t.Errorf("tier %d round %d: empty monster flavor", tier.Number, j+1)
}
if m.BaseLethality < 0.01 || m.BaseLethality > 0.99 {
t.Errorf("tier %d round %d: BaseLethality %f out of [0.01, 0.99]", tier.Number, j+1, m.BaseLethality)
}
if m.ThreatLevel <= 0 {
t.Errorf("tier %d round %d: ThreatLevel %d <= 0", tier.Number, j+1, m.ThreatLevel)
}
}
}
}
func TestArenaGetTier(t *testing.T) {
for i := 1; i <= 5; i++ {
tier := arenaGetTier(i)
if tier == nil {
t.Errorf("arenaGetTier(%d) returned nil", i)
}
}
if arenaGetTier(0) != nil {
t.Error("arenaGetTier(0) should return nil")
}
if arenaGetTier(6) != nil {
t.Error("arenaGetTier(6) should return nil")
}
}
func TestArenaGetMonster(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
for round := 1; round <= 4; round++ {
m := arenaGetMonster(tier, round)
if m == nil {
t.Errorf("arenaGetMonster(%d, %d) returned nil", tier, round)
}
}
}
if arenaGetMonster(1, 0) != nil {
t.Error("arenaGetMonster(1, 0) should return nil")
}
if arenaGetMonster(1, 5) != nil {
t.Error("arenaGetMonster(1, 5) should return nil")
}
}
func TestArenaMonsterLethality_Increasing(t *testing.T) {
// Within each tier, lethality should increase per round
for i, tier := range arenaTiers {
for j := 1; j < 4; j++ {
if tier.Monsters[j].BaseLethality < tier.Monsters[j-1].BaseLethality {
t.Errorf("tier %d: round %d lethality (%f) < round %d (%f)",
i+1, j+1, tier.Monsters[j].BaseLethality, j, tier.Monsters[j-1].BaseLethality)
}
}
}
}
func TestArenaMinLevel_Increasing(t *testing.T) {
for i := 1; i < 5; i++ {
if arenaTiers[i].MinLevel <= arenaTiers[i-1].MinLevel {
t.Errorf("tier %d MinLevel (%d) <= tier %d MinLevel (%d)",
i+1, arenaTiers[i].MinLevel, i, arenaTiers[i-1].MinLevel)
}
}
}
// ── Death Chance Formula Tests ──────────────────────────────────────────────
func TestArenaDeathChance_Clamped(t *testing.T) {
// Min-level player with tier 0 gear vs easiest monster
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := arenaGetMonster(1, 1)
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 || dc > 0.98 {
t.Errorf("death chance %f out of [0.01, 0.98] bounds", dc)
}
}
func TestArenaDeathChance_MaxGearReduces(t *testing.T) {
// Use a mid-tier monster where gear difference is visible above the floor
char := &AdventureCharacter{CombatLevel: 25}
monster := arenaGetMonster(3, 3) // Behemoth Adjacent (0.73 lethality)
noGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
maxGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
dcNoGear := arenaDeathChance(monster, char, noGear)
dcMaxGear := arenaDeathChance(monster, char, maxGear)
if dcMaxGear >= dcNoGear {
t.Errorf("max gear (%f) should reduce death chance vs no gear (%f)", dcMaxGear, dcNoGear)
}
}
func TestArenaDeathChance_HighLevelReduces(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3}, SlotArmor: {Tier: 3}, SlotHelmet: {Tier: 3},
SlotBoots: {Tier: 3}, SlotTool: {Tier: 3},
}
monster := arenaGetMonster(3, 4) // The Inevitable
lowLevel := &AdventureCharacter{CombatLevel: 25}
highLevel := &AdventureCharacter{CombatLevel: 50}
dcLow := arenaDeathChance(monster, lowLevel, equip)
dcHigh := arenaDeathChance(monster, highLevel, equip)
if dcHigh >= dcLow {
t.Errorf("high level (%f) should have lower death chance than low level (%f)", dcHigh, dcLow)
}
}
func TestArenaDeathChance_T5R4_AlwaysTerrifying(t *testing.T) {
// Even max-everything player faces high death at T5R4
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := arenaGetMonster(5, 4) // That Which Has Always Been
dc := arenaDeathChance(monster, char, equip)
if dc < 0.30 {
t.Errorf("T5R4 death chance for max player (%f) should be >= 0.30", dc)
}
// Should hit the 0.98 ceiling
if dc > 0.98 {
t.Errorf("T5R4 death chance (%f) exceeds ceiling 0.98", dc)
}
}
func TestArenaDeathChance_Floor(t *testing.T) {
// Even with absurd stats, floor is 0.01
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := &ArenaMonster{BaseLethality: 0.01, ThreatLevel: 1}
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 {
t.Errorf("death chance %f below floor 0.01", dc)
}
}
// ── Reward Formula Tests ────────────────────────────────────────────────────
func TestArenaRoundReward(t *testing.T) {
tier1 := arenaGetTier(1)
// Round 1, no skill bonus
r := arenaRoundReward(tier1, 1, 0)
if r != 150 {
t.Errorf("T1R1 skill=0: got %d, want 150", r)
}
// Round 4, no skill bonus
r = arenaRoundReward(tier1, 4, 0)
if r != 600 {
t.Errorf("T1R4 skill=0: got %d, want 600", r)
}
// Round 1 with skill
r = arenaRoundReward(tier1, 1, 10)
// base=150, skill_bonus=floor(10*1.0)=10
if r != 160 {
t.Errorf("T1R1 skill=10: got %d, want 160", r)
}
// Tier 5 round 4, max skill
tier5 := arenaGetTier(5)
r = arenaRoundReward(tier5, 4, 50)
// base=15000*4=60000, skill_bonus=floor(50*25.0)=1250
if r != 61250 {
t.Errorf("T5R4 skill=50: got %d, want 61250", r)
}
}
func TestArenaRewardScaling(t *testing.T) {
// Rewards should increase with round number
tier := arenaGetTier(3)
prev := int64(0)
for round := 1; round <= 4; round++ {
r := arenaRoundReward(tier, round, 20)
if r <= prev {
t.Errorf("T3R%d reward (%d) should exceed R%d (%d)", round, r, round-1, prev)
}
prev = r
}
}
// ── Death Chance Formula Component Tests ────────────────────────────────────
func TestArenaDeathChance_Components(t *testing.T) {
// Verify formula components individually
// death_chance = base + level_mod - equip_mod + skill_mod
// Level 1, tier 0 gear, T1R1 monster (lethality=0.10, threat=2)
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := &ArenaMonster{BaseLethality: 0.10, ThreatLevel: 2}
dc := arenaDeathChance(monster, char, equip)
// Manual calculation:
// base = 0.10
// level_mod = (2-1) * 0.015 = 0.015
// skill_mod = max(0, 0.25 - 1*0.008) = 0.242
// equip_mod = 0 * 0.03 = 0 (tier 0 gives no equipment bonus)
// death_chance = 0.10 + 0.015 - 0 + 0.242 = 0.357
expected := 0.10 + 0.015 + 0.242
if math.Abs(dc-expected) > 0.001 {
t.Errorf("T1R1 components: got %f, expected ~%f", dc, expected)
}
}
// ── Render Tests ────────────────────────────────────────────────────────────
func TestRenderArenaStreakEntry(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 30}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
// nil stats — no tiers cleared
text := renderArenaStreakEntry(char, nil, tier, monster)
if !strings.Contains(text, "THE ARENA") {
t.Error("streak entry should contain header")
}
if !strings.Contains(text, "Tier 1") {
t.Error("streak entry should list tier 1")
}
if !strings.Contains(text, "Tier 5") {
t.Error("streak entry should list tier 5")
}
if !strings.Contains(text, "streak") {
t.Error("streak entry should mention streak")
}
if !strings.Contains(text, "⬚") {
t.Error("streak entry should show eligible-but-uncleared tiers")
}
if !strings.Contains(text, "🔒") {
t.Error("streak entry should show locked tiers")
}
// with stats — tier 2 cleared
stats := &ArenaPersonalStats{TotalRuns: 5, TotalDeaths: 1, TotalEarnings: 3000, HighestTier: 2}
text = renderArenaStreakEntry(char, stats, tier, monster)
if !strings.Contains(text, "✅") {
t.Error("streak entry should show cleared tiers when stats provided")
}
if !strings.Contains(text, "Runs: 5") {
t.Error("streak entry should show run summary when stats provided")
}
}
func TestRenderArenaRoundStart(t *testing.T) {
tier := arenaGetTier(2)
monster := arenaGetMonster(2, 1)
run := &ArenaRun{Earnings: 500}
text := renderArenaRoundStart(tier, 1, monster, run)
if !strings.Contains(text, "Tier 2") {
t.Error("round start should show tier")
}
if !strings.Contains(text, monster.Name) {
t.Error("round start should show monster name")
}
if !strings.Contains(text, "€500") {
t.Error("round start should show earnings")
}
if !strings.Contains(text, "!arena fight") {
t.Error("round start should show fight command")
}
}
func TestRenderArenaDeath(t *testing.T) {
tier := arenaGetTier(3)
monster := arenaGetMonster(3, 2)
text := renderArenaDeath(tier, 2, monster, 5000, "The Arena has collected its fee.")
if !strings.Contains(text, "DEAD") {
t.Error("death should say DEAD")
}
if !strings.Contains(text, "€5000") {
t.Error("death should show forfeited earnings")
}
if !strings.Contains(text, "midnight UTC") {
t.Error("death should mention lockout")
}
}
func TestArenaStreakMultipliers(t *testing.T) {
// Verify multiplier arrays are sane
for tier := 1; tier <= 5; tier++ {
if arenaStreakEuroMultiplier[tier] < 1.0 {
t.Errorf("tier %d euro multiplier %f < 1.0", tier, arenaStreakEuroMultiplier[tier])
}
}
for tiers := 1; tiers <= 5; tiers++ {
if arenaStreakXPMultiplier[tiers] < 1.0 {
t.Errorf("tiers won %d XP multiplier %f < 1.0", tiers, arenaStreakXPMultiplier[tiers])
}
}
// Multipliers should be non-decreasing
for i := 2; i <= 5; i++ {
if arenaStreakEuroMultiplier[i] < arenaStreakEuroMultiplier[i-1] {
t.Errorf("euro multiplier tier %d (%f) < tier %d (%f)", i, arenaStreakEuroMultiplier[i], i-1, arenaStreakEuroMultiplier[i-1])
}
if arenaStreakXPMultiplier[i] < arenaStreakXPMultiplier[i-1] {
t.Errorf("XP multiplier %d tiers (%f) < %d tiers (%f)", i, arenaStreakXPMultiplier[i], i-1, arenaStreakXPMultiplier[i-1])
}
}
// Verify exact values match spec
wantEuro := map[int]float64{1: 1.0, 2: 1.5, 3: 2.0, 4: 2.75, 5: 4.0}
for tier, want := range wantEuro {
if arenaStreakEuroMultiplier[tier] != want {
t.Errorf("euro multiplier tier %d: got %f, want %f", tier, arenaStreakEuroMultiplier[tier], want)
}
}
wantXP := map[int]float64{1: 1.0, 2: 1.2, 3: 1.5, 4: 1.85, 5: 2.5}
for tiers, want := range wantXP {
if arenaStreakXPMultiplier[tiers] != want {
t.Errorf("XP multiplier %d tiers: got %f, want %f", tiers, arenaStreakXPMultiplier[tiers], want)
}
}
// Index 0 should be 0 (unused sentinel)
if arenaStreakEuroMultiplier[0] != 0 {
t.Errorf("euro multiplier index 0 should be 0, got %f", arenaStreakEuroMultiplier[0])
}
if arenaStreakXPMultiplier[0] != 0 {
t.Errorf("XP multiplier index 0 should be 0, got %f", arenaStreakXPMultiplier[0])
}
}
// ── Streak Reward Calculation Tests ────────────────────────────────────────
func TestArenaStreakPayout_SingleTier(t *testing.T) {
// Bail after T1: multiplier = 1.0× on T1 earnings
tier1 := arenaGetTier(1)
var tierEarnings int64
for round := 1; round <= 4; round++ {
tierEarnings += arenaRoundReward(tier1, round, 0)
}
tierEarnings += tier1.CompletionBonus
multiplied := int64(float64(tierEarnings) * arenaStreakEuroMultiplier[1])
if multiplied != tierEarnings {
t.Errorf("T1 payout with 1.0× multiplier should equal raw: got %d, want %d", multiplied, tierEarnings)
}
}
func TestArenaStreakPayout_FullRun(t *testing.T) {
// Full T1-T5 run: each tier's earnings multiplied independently
var totalMultiplied int64
var totalRaw int64
for tierNum := 1; tierNum <= 5; tierNum++ {
tier := arenaGetTier(tierNum)
var tierEarnings int64
for round := 1; round <= 4; round++ {
tierEarnings += arenaRoundReward(tier, round, 0)
}
tierEarnings += tier.CompletionBonus
totalRaw += tierEarnings
totalMultiplied += int64(float64(tierEarnings) * arenaStreakEuroMultiplier[tierNum])
}
// Multiplied total should exceed raw total (multipliers > 1 for T2+)
if totalMultiplied <= totalRaw {
t.Errorf("multiplied total (%d) should exceed raw total (%d)", totalMultiplied, totalRaw)
}
// T5 multiplier is 4.0×, so multiplied should be significantly higher
ratio := float64(totalMultiplied) / float64(totalRaw)
if ratio < 1.5 {
t.Errorf("overall multiplier ratio %f seems too low for streak system", ratio)
}
}
func TestArenaStreakXPMultiplier_Application(t *testing.T) {
// XP multiplier is applied to total session XP at payout
rawXP := 500
for tiersWon := 1; tiersWon <= 5; tiersWon++ {
mult := arenaStreakXPMultiplier[tiersWon]
totalXP := int(float64(rawXP) * mult)
if tiersWon == 1 && totalXP != rawXP {
t.Errorf("1 tier won: XP should be unmodified, got %d want %d", totalXP, rawXP)
}
if tiersWon > 1 && totalXP <= rawXP {
t.Errorf("%d tiers won: XP %d should exceed raw %d", tiersWon, totalXP, rawXP)
}
}
}
func TestArenaStreakPayout_TierEarningsReset(t *testing.T) {
// Simulate accumulation: tier earnings should reset per tier
run := &ArenaRun{Earnings: 0, TierEarnings: 0}
// Simulate T1 rounds
for round := 1; round <= 4; round++ {
tier := arenaGetTier(1)
run.TierEarnings += arenaRoundReward(tier, round, 10)
}
run.TierEarnings += arenaGetTier(1).CompletionBonus
// Apply T1 multiplier
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[1])
t1Total := run.Earnings
run.TierEarnings = 0
if t1Total <= 0 {
t.Fatal("T1 earnings should be positive")
}
if run.TierEarnings != 0 {
t.Error("TierEarnings should be 0 after reset")
}
// Simulate T2 rounds
for round := 1; round <= 4; round++ {
tier := arenaGetTier(2)
run.TierEarnings += arenaRoundReward(tier, round, 10)
}
run.TierEarnings += arenaGetTier(2).CompletionBonus
// Apply T2 multiplier (1.5×)
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[2])
run.TierEarnings = 0
if run.Earnings <= t1Total {
t.Errorf("session total after T2 (%d) should exceed T1 total (%d)", run.Earnings, t1Total)
}
}
func TestArenaStreakPayout_DeathForfeitsAll(t *testing.T) {
// On death, earnings, tier_earnings, and XP are all zeroed
run := &ArenaRun{
Earnings: 50000,
TierEarnings: 3000,
XPAccumulated: 200,
}
// Simulate death
run.Earnings = 0
run.TierEarnings = 0
run.XPAccumulated = 0
if run.Earnings != 0 || run.TierEarnings != 0 || run.XPAccumulated != 0 {
t.Error("death should forfeit all session state")
}
}
// ── Gladiator's Helm Tests ─────────────────────────────────────────────────
func TestGladiatorHelmDropRates(t *testing.T) {
if gladiatorHelmDropT4 != 0.08 {
t.Errorf("T4 drop rate: got %f, want 0.08", gladiatorHelmDropT4)
}
if gladiatorHelmDropT5 != 0.18 {
t.Errorf("T5 drop rate: got %f, want 0.18", gladiatorHelmDropT5)
}
if gladiatorHelmDropT5 <= gladiatorHelmDropT4 {
t.Error("T5 drop rate should exceed T4")
}
}
func TestGladiatorHelmTier(t *testing.T) {
if gladiatorHelmTier != 5 {
t.Errorf("Gladiator's Helm tier: got %d, want 5", gladiatorHelmTier)
}
}
// ── Arena Gear Tests ──────────────────────────────────────────────────────
func TestArenaGearByTier(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
gear := arenaGearByTier(tier)
if gear == nil {
t.Errorf("arenaGearByTier(%d) returned nil", tier)
continue
}
if gear.Tier != tier {
t.Errorf("arenaGearByTier(%d).Tier = %d", tier, gear.Tier)
}
if gear.SetKey == "" {
t.Errorf("tier %d: empty SetKey", tier)
}
if gear.HelmetName == "" {
t.Errorf("tier %d: empty HelmetName", tier)
}
if gear.DropRate <= 0 || gear.DropRate >= 1 {
t.Errorf("tier %d: DropRate %f out of (0, 1)", tier, gear.DropRate)
}
}
// Out of bounds
if arenaGearByTier(0) != nil {
t.Error("arenaGearByTier(0) should return nil")
}
if arenaGearByTier(6) != nil {
t.Error("arenaGearByTier(6) should return nil")
}
}
func TestArenaGearDropRates_Decreasing(t *testing.T) {
// Higher tiers should have lower (or equal) drop rates
for i := 2; i <= 5; i++ {
curr := arenaGearByTier(i)
prev := arenaGearByTier(i - 1)
if curr.DropRate > prev.DropRate {
t.Errorf("tier %d drop rate (%f) > tier %d (%f)", i, curr.DropRate, i-1, prev.DropRate)
}
}
}
// ── Render Tests (New/Updated) ─────────────────────────────────────────────
func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 50}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaStreakEntry(char, nil, tier, monster)
// Should show streak multiplier for at least T1
if !strings.Contains(text, "1.0×") {
t.Error("streak entry should show T1 multiplier")
}
if !strings.Contains(text, "4.0×") {
t.Error("streak entry should show T5 multiplier")
}
// Should show first monster
if !strings.Contains(text, monster.Name) {
t.Error("streak entry should show first monster name")
}
// Should show fight command
if !strings.Contains(text, "!arena fight") {
t.Error("streak entry should show fight command")
}
// Should show cancel option
if !strings.Contains(text, "!arena cancel") {
t.Error("streak entry should show cancel command")
}
}
func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) {
// T5 requires level 70, so use level 70+ to unlock everything
char := &AdventureCharacter{CombatLevel: 70}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaStreakEntry(char, nil, tier, monster)
// Level 70 should make all tiers eligible (no 🔒)
if strings.Contains(text, "🔒") {
t.Error("level 70 should have all tiers eligible (no locked icons)")
}
}
func TestRenderArenaRoundStart_WithTierEarnings(t *testing.T) {
tier := arenaGetTier(3)
monster := arenaGetMonster(3, 1)
run := &ArenaRun{Earnings: 10000, TierEarnings: 2000}
text := renderArenaRoundStart(tier, 1, monster, run)
// Should show combined session total (Earnings + TierEarnings)
if !strings.Contains(text, "€12000") {
t.Error("round start should show combined session total (10000+2000)")
}
}
func TestRenderArenaRoundStart_ZeroEarnings(t *testing.T) {
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
run := &ArenaRun{Earnings: 0, TierEarnings: 0}
text := renderArenaRoundStart(tier, 1, monster, run)
// Should NOT show earnings line when zero
if strings.Contains(text, "Session earnings") {
t.Error("round start should not show earnings line when zero")
}
}
func TestRenderArenaStatus_Active(t *testing.T) {
run := &ArenaRun{Tier: 2, Round: 3, Status: "active", Earnings: 8000, TierEarnings: 1500, RoundsSurvived: 6}
char := &AdventureCharacter{CombatLevel: 20}
text := renderArenaStatus(run, char)
if !strings.Contains(text, "Streak") {
t.Error("status should say Arena Streak")
}
if !strings.Contains(text, "€9500") {
t.Error("status should show combined session total (8000+1500)")
}
if !strings.Contains(text, "Round: 3/4") {
t.Error("status should show round")
}
if !strings.Contains(text, "!arena fight") {
t.Error("active status should show fight command")
}
}
func TestRenderArenaStatus_Awaiting(t *testing.T) {
run := &ArenaRun{Tier: 3, Status: "awaiting", Earnings: 20000, TierEarnings: 0, RoundsSurvived: 12}
char := &AdventureCharacter{CombatLevel: 30}
text := renderArenaStatus(run, char)
if !strings.Contains(text, "advancing shortly") {
t.Error("awaiting status should mention advancing")
}
if !strings.Contains(text, "!bail") {
t.Error("awaiting status should show bail command")
}
if !strings.Contains(text, "€20000") {
t.Error("awaiting status should show session total")
}
}
func TestRenderArenaAlreadyInRun_Active(t *testing.T) {
run := &ArenaRun{Tier: 2, Round: 3, Status: "active"}
text := renderArenaAlreadyInRun(run)
if !strings.Contains(text, "Tier 2") {
t.Error("active run message should show tier")
}
if !strings.Contains(text, "!arena fight") {
t.Error("active run message should show fight command")
}
}
func TestRenderArenaAlreadyInRun_Awaiting(t *testing.T) {
run := &ArenaRun{Tier: 3, Status: "awaiting", Earnings: 15000}
text := renderArenaAlreadyInRun(run)
if !strings.Contains(text, "Tier 3") {
t.Error("awaiting run message should show tier")
}
if !strings.Contains(text, "!bail") {
t.Error("awaiting run message should mention bail")
}
if !strings.Contains(text, "€15000") {
t.Error("awaiting run message should show accumulated euros")
}
}
func TestRenderArenaHelmetDrop_AllSets(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
gear := arenaGearByTier(tier)
text := renderArenaHelmetDrop(gear)
if !strings.Contains(text, gear.HelmetName) {
t.Errorf("tier %d: helmet drop should show helmet name", tier)
}
if !strings.Contains(text, "Set bonus") {
t.Errorf("tier %d: helmet drop should show set bonus", tier)
}
if !strings.Contains(text, "Equipped automatically") {
t.Errorf("tier %d: helmet drop should mention auto-equip", tier)
}
}
}
func TestRenderArenaDeathReprieve(t *testing.T) {
nextWindow := time.Date(2026, 4, 15, 14, 30, 0, 0, time.UTC)
text := renderArenaDeathReprieve("Alice", "Ratticus the Persistent", nextWindow)
if !strings.Contains(text, "Alice") {
t.Error("reprieve should mention player name")
}
if !strings.Contains(text, "Death's Reprieve") {
t.Error("reprieve should mention the ability name")
}
if !strings.Contains(text, "2026-04-15") {
t.Error("reprieve should show next window date")
}
}
func TestRenderArenaPersonalStats_NoStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "TestPlayer"}
text := renderArenaPersonalStats(char, nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty stats should say no runs")
}
}
func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "Alice", ArenaWins: 8, ArenaLosses: 3}
stats := &ArenaPersonalStats{
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
HighestTier: 5, Tier5Completions: 2,
}
text := renderArenaPersonalStats(char, stats)
if !strings.Contains(text, "Alice") {
t.Error("stats should show player name")
}
if !strings.Contains(text, "€150000") {
t.Error("stats should show total earnings")
}
if !strings.Contains(text, "Tier 5 completions: 2") {
t.Error("stats should show T5 completions")
}
if !strings.Contains(text, "8/3") {
t.Error("stats should show W/L record")
}
}
func TestRenderArenaLeaderboard_Empty(t *testing.T) {
text := renderArenaLeaderboard(nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty leaderboard should say no runs")
}
}
func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
entries := []ArenaLeaderboardEntry{
{DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
}
text := renderArenaLeaderboard(entries)
if !strings.Contains(text, "Alice") {
t.Error("leaderboard should contain Alice")
}
if !strings.Contains(text, "Bob") {
t.Error("leaderboard should contain Bob")
}
if !strings.Contains(text, "T5×1") {
t.Error("leaderboard should show T5 completions")
}
}
func TestRenderArenaLevelGate(t *testing.T) {
tier := arenaGetTier(3)
text := renderArenaLevelGate(tier, 10)
if !strings.Contains(text, "Level 25") {
t.Error("gate message should show required level")
}
if !strings.Contains(text, "Level 10") {
t.Error("gate message should show player level")
}
}
func TestRenderArenaStatus(t *testing.T) {
run := &ArenaRun{Tier: 3, Round: 2, Status: "active", Earnings: 5000, RoundsSurvived: 5}
char := &AdventureCharacter{CombatLevel: 30}
text := renderArenaStatus(run, char)
if !strings.Contains(text, "Tier: 3") {
t.Error("status should show tier")
}
if !strings.Contains(text, "Round: 2/4") {
t.Error("status should show round")
}
if !strings.Contains(text, "€5000") {
t.Error("status should show earnings")
}
}
// ── Death Message Tests ─────────────────────────────────────────────────────
func TestArenaDeathMessages_NotEmpty(t *testing.T) {
if len(arenaDeathMessages) == 0 {
t.Error("arenaDeathMessages pool is empty")
}
if len(arenaMonsterDeathMessages) == 0 {
t.Error("arenaMonsterDeathMessages pool is empty")
}
}
func TestArenaPickDeathMessage(t *testing.T) {
monster := arenaGetMonster(2, 3) // The Impersonator
for i := 0; i < 20; i++ {
msg := arenaPickDeathMessage(monster, 2, 3)
if msg == "" {
t.Error("death message should not be empty")
}
}
}
// ── Full Run Reward Calculation ─────────────────────────────────────────────
func TestArenaFullRunRewards_Tier1(t *testing.T) {
// Calculate total earnings for a clean Tier 1 run (no skill)
tier := arenaGetTier(1)
var total int64
for round := 1; round <= 4; round++ {
total += arenaRoundReward(tier, round, 0)
}
total += tier.CompletionBonus
// Round rewards: 150 + 300 + 450 + 600 = 1500; + 2500 bonus = 4000
if total != 4000 {
t.Errorf("clean T1 run (no skill): got €%d, want €4000", total)
}
}
func TestArenaFullRunRewards_AllTiers(t *testing.T) {
// A full T1-T5 run with skill=0 should be well over 100k
var grandTotal int64
for tier := 1; tier <= 5; tier++ {
t := arenaGetTier(tier)
for round := 1; round <= 4; round++ {
grandTotal += arenaRoundReward(t, round, 0)
}
grandTotal += t.CompletionBonus
}
if grandTotal < 100000 {
// Sanity check that the numbers aren't accidentally tiny
t.Errorf("full T1-T5 run (no skill) = €%d, expected > €100,000", grandTotal)
}
}
// ── Full Streak Simulation ────────────────────────────────────────────────
func TestArenaStreakSimulation_FullRun(t *testing.T) {
// Simulate a complete T1-T5 streak: accumulate rewards as the real code does
run := &ArenaRun{
StartTier: 1,
Tier: 1,
Round: 1,
Status: "active",
Earnings: 0,
TierEarnings: 0,
XPAccumulated: 0,
}
combatLevel := 30
for tierNum := 1; tierNum <= 5; tierNum++ {
tier := arenaGetTier(tierNum)
run.Tier = tierNum
for round := 1; round <= 4; round++ {
run.Round = round
reward := arenaRoundReward(tier, round, combatLevel)
run.TierEarnings += reward
run.XPAccumulated += tier.BattleXP
run.RoundsSurvived++
}
// Tier complete — add completion bonus
run.TierEarnings += tier.CompletionBonus
// Apply streak multiplier
multiplier := arenaStreakEuroMultiplier[tierNum]
run.Earnings += int64(float64(run.TierEarnings) * multiplier)
run.TierEarnings = 0 // reset for next tier
}
// Verify tier earnings reset
if run.TierEarnings != 0 {
t.Errorf("TierEarnings should be 0 after all tiers, got %d", run.TierEarnings)
}
// Verify earnings are positive and substantial
if run.Earnings <= 0 {
t.Fatal("session earnings should be positive")
}
// Calculate what raw (unmultiplied) total would be
var rawTotal int64
for tierNum := 1; tierNum <= 5; tierNum++ {
tier := arenaGetTier(tierNum)
for round := 1; round <= 4; round++ {
rawTotal += arenaRoundReward(tier, round, combatLevel)
}
rawTotal += tier.CompletionBonus
}
// Multiplied total should exceed raw
if run.Earnings <= rawTotal {
t.Errorf("multiplied total (%d) should exceed raw total (%d)", run.Earnings, rawTotal)
}
// Verify XP accumulated across 20 rounds (4 rounds × 5 tiers)
if run.RoundsSurvived != 20 {
t.Errorf("rounds survived: got %d, want 20", run.RoundsSurvived)
}
if run.XPAccumulated <= 0 {
t.Error("XP should be accumulated")
}
// Apply XP multiplier for 5 tiers won
xpMult := arenaStreakXPMultiplier[5]
totalXP := int(float64(run.XPAccumulated) * xpMult)
if totalXP <= run.XPAccumulated {
t.Errorf("multiplied XP (%d) should exceed raw XP (%d) with 5-tier multiplier", totalXP, run.XPAccumulated)
}
// 2.5× multiplier means totalXP should be 2.5× raw
expectedXP := int(float64(run.XPAccumulated) * 2.5)
if totalXP != expectedXP {
t.Errorf("total XP: got %d, want %d (2.5× raw)", totalXP, expectedXP)
}
}
func TestArenaStreakSimulation_BailAfterT3(t *testing.T) {
// Simulate bail after clearing T3
run := &ArenaRun{Earnings: 0, TierEarnings: 0, XPAccumulated: 0}
for tierNum := 1; tierNum <= 3; tierNum++ {
tier := arenaGetTier(tierNum)
for round := 1; round <= 4; round++ {
run.TierEarnings += arenaRoundReward(tier, round, 20)
run.XPAccumulated += tier.BattleXP
}
run.TierEarnings += tier.CompletionBonus
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[tierNum])
run.TierEarnings = 0
}
// XP multiplier for 3 tiers = 1.5×
totalXP := int(float64(run.XPAccumulated) * arenaStreakXPMultiplier[3])
if totalXP <= run.XPAccumulated {
t.Error("multiplied XP should exceed raw with 3-tier multiplier")
}
if run.Earnings <= 0 {
t.Error("session earnings should be positive after 3 tiers")
}
}
func TestArenaStreakSimulation_DeathMidTier(t *testing.T) {
// Simulate death in T2R3 — should have accumulated T1 earnings + partial T2
run := &ArenaRun{Earnings: 0, TierEarnings: 0, XPAccumulated: 0}
// Complete T1
tier1 := arenaGetTier(1)
for round := 1; round <= 4; round++ {
run.TierEarnings += arenaRoundReward(tier1, round, 10)
run.XPAccumulated += tier1.BattleXP
}
run.TierEarnings += tier1.CompletionBonus
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[1])
t1Earnings := run.Earnings
run.TierEarnings = 0
// Partial T2 — 2 rounds
tier2 := arenaGetTier(2)
for round := 1; round <= 2; round++ {
run.TierEarnings += arenaRoundReward(tier2, round, 10)
run.XPAccumulated += tier2.BattleXP
}
// Death — forfeit everything
lostTotal := run.Earnings + run.TierEarnings
if lostTotal <= t1Earnings {
t.Error("lost total should include partial T2 earnings")
}
run.Earnings = 0
run.TierEarnings = 0
run.XPAccumulated = 0
if run.Earnings != 0 || run.TierEarnings != 0 || run.XPAccumulated != 0 {
t.Error("all session state should be zero after death")
}
}
func TestArenaRunFields_NewFieldsDefault(t *testing.T) {
run := ArenaRun{}
if run.TierEarnings != 0 {
t.Errorf("default TierEarnings should be 0, got %d", run.TierEarnings)
}
if run.XPAccumulated != 0 {
t.Errorf("default XPAccumulated should be 0, got %d", run.XPAccumulated)
}
}
// ── Streak Euro Multiplier Math (Exact) ───────────────────────────────────
func TestArenaStreakEuroMultiplier_Exact(t *testing.T) {
// T1 raw = 4000 (verified in TestArenaFullRunRewards_Tier1)
// T1 multiplied = 4000 * 1.0 = 4000
tier1 := arenaGetTier(1)
var t1Raw int64
for r := 1; r <= 4; r++ {
t1Raw += arenaRoundReward(tier1, r, 0)
}
t1Raw += tier1.CompletionBonus
t1Mult := int64(float64(t1Raw) * arenaStreakEuroMultiplier[1])
if t1Mult != 4000 {
t.Errorf("T1 multiplied (1.0×): got %d, want 4000", t1Mult)
}
// T2 raw with skill=0
tier2 := arenaGetTier(2)
var t2Raw int64
for r := 1; r <= 4; r++ {
t2Raw += arenaRoundReward(tier2, r, 0)
}
t2Raw += tier2.CompletionBonus
// T2 rounds: 500+1000+1500+2000 = 5000, +10000 bonus = 15000
if t2Raw != 15000 {
t.Errorf("T2 raw: got %d, want 15000", t2Raw)
}
// T2 multiplied: 15000 * 1.5 = 22500
t2Mult := int64(float64(t2Raw) * arenaStreakEuroMultiplier[2])
if t2Mult != 22500 {
t.Errorf("T2 multiplied (1.5×): got %d, want 22500", t2Mult)
}
}
// ── Streak Entry Shows Brim & Battle Sponsorship ──────────────────────────
func TestArenaStreakEntry_Sponsorship(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 1}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaStreakEntry(char, nil, tier, monster)
if !strings.Contains(text, "Brim & Battle") {
t.Error("arena entry should include Brim & Battle sponsorship")
}
}
// ── Help Text Tests ──────────────────────────────────────────────────────
func TestArenaHelpText_StreakContent(t *testing.T) {
if !strings.Contains(arenaHelpText, "!bail") {
t.Error("help text should mention !bail")
}
if !strings.Contains(arenaHelpText, "streak") {
t.Error("help text should mention streak")
}
if strings.Contains(arenaHelpText, "!arena tier") {
t.Error("help text should NOT mention !arena tier (removed)")
}
if strings.Contains(arenaHelpText, "!arena descend") {
t.Error("help text should NOT mention !arena descend (removed)")
}
if strings.Contains(arenaHelpText, "!arena cashout") {
t.Error("help text should NOT mention !arena cashout (removed)")
}
}
// ── Gladiator's Helm Drop Rate Logic ──────────────────────────────────────
func TestGladiatorHelmDropRate_BelowT4(t *testing.T) {
// maxTierCleared < 4 should never produce a helm
// (We can't test the actual RNG roll, but we can verify the gate)
for tier := 1; tier <= 3; tier++ {
// The function gates on maxTierCleared < 4, so these tiers should
// never even reach the RNG check
if tier >= 4 {
t.Error("test logic error")
}
}
}
func TestGladiatorHelmDropRate_Selection(t *testing.T) {
// At T4, rate should be 0.08; at T5, rate should be 0.18
// We test the conditional selection logic
if gladiatorHelmDropT4 >= gladiatorHelmDropT5 {
t.Error("T5 drop rate should be higher than T4")
}
// Both should be in (0, 1)
if gladiatorHelmDropT4 <= 0 || gladiatorHelmDropT4 >= 1 {
t.Errorf("T4 rate %f out of (0, 1)", gladiatorHelmDropT4)
}
if gladiatorHelmDropT5 <= 0 || gladiatorHelmDropT5 >= 1 {
t.Errorf("T5 rate %f out of (0, 1)", gladiatorHelmDropT5)
}
}