diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 4a5c47b..6e8f0d0 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -346,8 +346,11 @@ var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult, bossNarr *arenaBossNarration) error { - // Calculate reward — accumulate in tier earnings, not credited yet - reward := arenaRoundReward(tier, run.Round, char.CombatLevel) + // Calculate reward — accumulate in tier earnings, not credited yet. + // Skill bonus scales off DnDCharacter.Level (post-L2: arena is on the + // D&D level scale, not the legacy CombatLevel). + skillLevel := arenaDnDLevelOrZero(ctx.Sender) + reward := arenaRoundReward(tier, run.Round, skillLevel) run.TierEarnings += reward run.RoundsSurvived++ run.LastMonster = monster.Name @@ -712,37 +715,15 @@ func (p *AdventurePlugin) arenaProcessBail(userID id.UserID, run *ArenaRun) erro // ── Combat Math ───────────────────────────────────────────────────────────── -func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment) float64 { - baseDeath := monster.BaseLethality - levelMod := float64(monster.ThreatLevel-char.CombatLevel) * 0.015 - skillMod := math.Max(0, 0.25-float64(char.CombatLevel)*0.008) - - // Average equipment tier → up to 15% reduction at max gear (tier 5 avg = 0.15) - var totalTier float64 - count := 0 - for _, slot := range allSlots { - if eq, ok := equip[slot]; ok { - totalTier += float64(eq.Tier) - count++ - } +// arenaDnDLevelOrZero returns the player's DnDCharacter.Level, or 0 if no +// sheet exists. Arena enters this path after combat (which migrates), so a +// zero return is rare and the caller's MinLevel gate handles it correctly. +func arenaDnDLevelOrZero(userID id.UserID) int { + c, err := LoadDnDCharacter(userID) + if err != nil || c == nil { + return 0 } - avgTier := 0.0 - if count > 0 { - avgTier = totalTier / float64(count) - } - equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5 - - // Housing HP bonus reduces death chance - houseMod := char.HouseHPBonus() // 0-20% based on house tier - - // Pet morning defense buff (cat offering / dog smothering) - petDefMod := 0.0 - if char.PetMorningDefense { - petDefMod = 0.05 - } - - deathChance := baseDeath + levelMod - equipMod + skillMod - houseMod - petDefMod - return math.Max(0.01, math.Min(0.98, deathChance)) + return c.Level } func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 { @@ -860,16 +841,11 @@ func (p *AdventurePlugin) arenaCountdown(userID id.UserID, run *ArenaRun) { return } - char, err := loadAdvCharacter(userID) - if err != nil { - slog.Error("arena: failed to load character for auto-advance", "user", userID, "err", err) - p.arenaProcessBail(userID, freshRun) - return - } - - // Level gate for next tier - if char.CombatLevel < nextTier.MinLevel { - p.SendDM(userID, renderArenaLevelGate(nextTier, char.CombatLevel)+"\n\nYour accumulated rewards have been paid out.") + // Level gate for next tier — uses DnDCharacter.Level (post-L2 arena + // is on the D&D level scale). + playerLevel := arenaDnDLevelOrZero(userID) + if playerLevel < nextTier.MinLevel { + p.SendDM(userID, renderArenaLevelGate(nextTier, playerLevel)+"\n\nYour accumulated rewards have been paid out.") p.arenaProcessBail(userID, freshRun) return } diff --git a/internal/plugin/adventure_arena_monsters.go b/internal/plugin/adventure_arena_monsters.go index 36c2f8d..2cec8c2 100644 --- a/internal/plugin/adventure_arena_monsters.go +++ b/internal/plugin/adventure_arena_monsters.go @@ -29,7 +29,7 @@ type ArenaMonster struct { var arenaTiers = [5]ArenaTier{ // Tier 1 — Scrubs { - Number: 1, Name: "Scrubs", MinLevel: 1, + Number: 1, Name: "Scrubs", MinLevel: 1, // DnD Level 1–3 BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10, Monsters: [4]ArenaMonster{ { @@ -56,7 +56,7 @@ var arenaTiers = [5]ArenaTier{ }, // Tier 2 — Thugs { - Number: 2, Name: "Thugs", MinLevel: 10, + Number: 2, Name: "Thugs", MinLevel: 4, // DnD Level 4–7 BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25, Monsters: [4]ArenaMonster{ { @@ -84,7 +84,7 @@ var arenaTiers = [5]ArenaTier{ }, // Tier 3 — Brutes { - Number: 3, Name: "Brutes", MinLevel: 25, + Number: 3, Name: "Brutes", MinLevel: 8, // DnD Level 8–12 BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60, Monsters: [4]ArenaMonster{ { @@ -113,7 +113,7 @@ var arenaTiers = [5]ArenaTier{ }, // Tier 4 — Horrors { - Number: 4, Name: "Horrors", MinLevel: 45, + Number: 4, Name: "Horrors", MinLevel: 13, // DnD Level 13–17 BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120, Monsters: [4]ArenaMonster{ { @@ -143,7 +143,7 @@ var arenaTiers = [5]ArenaTier{ }, // Tier 5 — World Eaters { - Number: 5, Name: "World Eaters", MinLevel: 70, + Number: 5, Name: "World Eaters", MinLevel: 18, // DnD Level 18–20 BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250, Monsters: [4]ArenaMonster{ { diff --git a/internal/plugin/adventure_arena_test.go b/internal/plugin/adventure_arena_test.go index 4421c20..2102bac 100644 --- a/internal/plugin/adventure_arena_test.go +++ b/internal/plugin/adventure_arena_test.go @@ -1,7 +1,6 @@ package plugin import ( - "math" "strings" "testing" "time" @@ -107,96 +106,6 @@ func TestArenaMinLevel_Increasing(t *testing.T) { } } -// ── 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) { @@ -243,38 +152,11 @@ func TestArenaRewardScaling(t *testing.T) { } } -// ── 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 := &DnDCharacter{Level: 30} + // 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) @@ -603,8 +485,8 @@ func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) { } func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) { - // T5 requires level 70, so use level 70+ to unlock everything - char := &DnDCharacter{Level: 70} + // T5 requires level 18 (post-L2 D&D scale); level 20 unlocks everything. + char := &DnDCharacter{Level: 20} tier := arenaGetTier(1) monster := arenaGetMonster(1, 1) @@ -780,11 +662,11 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) { func TestRenderArenaLevelGate(t *testing.T) { tier := arenaGetTier(3) - text := renderArenaLevelGate(tier, 10) - if !strings.Contains(text, "Level 25") { + text := renderArenaLevelGate(tier, 5) + if !strings.Contains(text, "Level 8") { t.Error("gate message should show required level") } - if !strings.Contains(text, "Level 10") { + if !strings.Contains(text, "Level 5") { t.Error("gate message should show player level") } }