mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on the registry definition, so an upgraded instance records its progress as a `temper` step count on its own row (adventure_inventory, magic_item_equipped) and effective rarity is derived on read. The stored base rarity is never written back, so an item cannot double-bump across a load/save round-trip. Effects flow through the existing rarity scalars — no new combat math, and the Legendary cap means this accelerates reaching the current ceiling rather than raising it. Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30). Only the Legendary rung consumes a T5 material; gating the lower rungs on one would make tempering unreachable until a player already clears T5. Two plan anchors were wrong: thyraks_core and portal_fragment are not loot-note strings needing promotion — they are UniqueAlways slate entries that already materialize as inventory rows on every T5 clear. B4 — expedition achievement wing: first-clear and all-zones-cleared per tier (10), a quiet-clear for keeping peak threat under 50, and temper_legendary. The quiet-clear requires max_threat_seen to be *present*, not merely low: recordMaxThreat samples only on day rollover and never stores a zero, so an absent key means no threat history, not low threat. The plan's no-death-T4 achievement is not shipped — no per-expedition death counter exists — and pet-saved-my-life already ships as combat_pet_save. C4 — arena seasons. Standings are derived from arena_history.created_at per calendar quarter rather than wiping arena_stats: same visible reset, but lifetime totals survive for `!arena stats` and no destructive job can fire twice. Crowns archive to arena_season_titles rather than player_meta.title, which already carries the Survivalist milestone. Rollover rides the existing midnight ticker and self-dedups on the season key, so a bot that was down on the boundary catches up on its next wake.
994 lines
31 KiB
Go
994 lines
31 KiB
Go
package plugin
|
||
|
||
import (
|
||
"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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
}
|
||
}
|
||
|
||
// ── Render Tests ────────────────────────────────────────────────────────────
|
||
|
||
func TestRenderArenaStreakEntry(t *testing.T) {
|
||
// Level 5 unlocks T1+T2 only (post-L2 brackets: 1/4/8/13/18); T3-T5 locked.
|
||
char := &DnDCharacter{Level: 5}
|
||
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 := &DnDCharacter{Level: 20}
|
||
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 18 (post-L2 D&D scale); level 20 unlocks everything.
|
||
char := &DnDCharacter{Level: 20}
|
||
tier := arenaGetTier(1)
|
||
monster := arenaGetMonster(1, 1)
|
||
|
||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||
// Level 20 (D&D cap) should make all tiers eligible (no 🔒)
|
||
if strings.Contains(text, "🔒") {
|
||
t.Error("level 20 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}
|
||
|
||
text := renderArenaStatus(run)
|
||
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}
|
||
|
||
text := renderArenaStatus(run)
|
||
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) {
|
||
text := renderArenaPersonalStats("TestPlayer", 0, 0, nil)
|
||
if !strings.Contains(text, "No arena runs") {
|
||
t.Error("empty stats should say no runs")
|
||
}
|
||
}
|
||
|
||
func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
|
||
stats := &ArenaPersonalStats{
|
||
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
|
||
HighestTier: 5, Tier5Completions: 2,
|
||
}
|
||
text := renderArenaPersonalStats("Alice", 8, 3, 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("2026-Q3", nil)
|
||
if !strings.Contains(text, "Nobody has entered") {
|
||
t.Error("empty leaderboard should say nobody entered")
|
||
}
|
||
}
|
||
|
||
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("2026-Q3", 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, 5)
|
||
if !strings.Contains(text, "Level 8") {
|
||
t.Error("gate message should show required level")
|
||
}
|
||
if !strings.Contains(text, "Level 5") {
|
||
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}
|
||
|
||
text := renderArenaStatus(run)
|
||
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,
|
||
}
|
||
skillBonus := 20
|
||
|
||
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, skillBonus)
|
||
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, skillBonus)
|
||
}
|
||
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 := &DnDCharacter{Level: 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)
|
||
}
|
||
}
|