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

528 lines
16 KiB
Go
Raw 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"
)
// ── 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 TestRenderArenaTierMenu(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 30}
// nil stats — no tiers cleared
text := renderArenaTierMenu(char, nil)
if !strings.Contains(text, "THE ARENA") {
t.Error("tier menu should contain header")
}
if !strings.Contains(text, "Tier 1") {
t.Error("tier menu should list tier 1")
}
if !strings.Contains(text, "Tier 5") {
t.Error("tier menu should list tier 5")
}
// Level 30 should have tiers 1-3 eligible (⬚) and 4-5 locked (🔒)
if !strings.Contains(text, "⬚") {
t.Error("tier menu should show eligible-but-uncleared tiers")
}
if !strings.Contains(text, "🔒") {
t.Error("tier menu should show locked tiers")
}
// with stats — tier 2 cleared
stats := &ArenaPersonalStats{TotalRuns: 5, TotalDeaths: 1, TotalEarnings: 3000, HighestTier: 2}
text = renderArenaTierMenu(char, stats)
if !strings.Contains(text, "✅") {
t.Error("tier menu should show cleared tiers when stats provided")
}
if !strings.Contains(text, "Runs: 5") {
t.Error("tier menu 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 TestRenderArenaSurvival(t *testing.T) {
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaSurvival(tier, 1, monster, 150, 10, 150)
if !strings.Contains(text, "defeated") {
t.Error("survival should mention defeat")
}
if !strings.Contains(text, "€150") {
t.Error("survival should show reward")
}
if !strings.Contains(text, "+10") {
t.Error("survival should show XP")
}
}
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 TestRenderArenaTierComplete(t *testing.T) {
tier := arenaGetTier(2)
text := renderArenaTierComplete(tier, 10000, 15000)
if !strings.Contains(text, "cleared") {
t.Error("tier complete should say cleared")
}
if !strings.Contains(text, "€10000") {
t.Error("tier complete should show completion bonus")
}
if !strings.Contains(text, "descend") {
t.Error("tier complete should offer descend")
}
if !strings.Contains(text, "cashout") {
t.Error("tier complete should offer cashout")
}
if !strings.Contains(text, "10 minutes") {
t.Error("tier complete should mention deadline")
}
}
func TestRenderArenaTier5Complete(t *testing.T) {
// Non-full run
text := renderArenaTier5Complete(650000, 3)
if !strings.Contains(text, "CONQUERED") {
t.Error("T5 complete should say conquered")
}
if !strings.Contains(text, "€650000") {
t.Error("T5 complete should show earnings")
}
if strings.Contains(text, "All the way down") {
t.Error("non-full run should NOT mention all the way down")
}
// Full run (started from tier 1)
textFull := renderArenaTier5Complete(1000000, 1)
if !strings.Contains(textFull, "All the way down") {
t.Error("full run should mention all the way down")
}
}
func TestRenderArenaCashout(t *testing.T) {
text := renderArenaCashout(25000, 3)
if !strings.Contains(text, "€25000") {
t.Error("cashout should show amount")
}
if !strings.Contains(text, "Tier 3") {
t.Error("cashout should show last tier")
}
}
func TestRenderArenaAutoCashout(t *testing.T) {
text := renderArenaAutoCashout(10000)
if !strings.Contains(text, "Auto-cashout") {
t.Error("auto-cashout should identify itself")
}
if !strings.Contains(text, "€10000") {
t.Error("auto-cashout should show amount")
}
if !strings.Contains(text, "annoyed") {
t.Error("auto-cashout should mention GogoBee is annoyed")
}
}
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)
}
}