mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 Phase L2 step 7: arena tier gate → DnDCharacter.Level
- Rebracket arenaTiers MinLevel to D&D scale (1/4/8/13/18) so arena tiers gate on DnDCharacter.Level instead of legacy CombatLevel. - Auto-advance level gate + reward skill bonus now read Level via a new arenaDnDLevelOrZero(userID) helper. - Delete dead arenaDeathChance (combat engine owns death now) and its six tests; drop the math import from the test file. - Update streak-entry / level-gate tests to use D&D-scale levels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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 {
|
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
|
// Calculate reward — accumulate in tier earnings, not credited yet.
|
||||||
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
|
// 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.TierEarnings += reward
|
||||||
run.RoundsSurvived++
|
run.RoundsSurvived++
|
||||||
run.LastMonster = monster.Name
|
run.LastMonster = monster.Name
|
||||||
@@ -712,37 +715,15 @@ func (p *AdventurePlugin) arenaProcessBail(userID id.UserID, run *ArenaRun) erro
|
|||||||
|
|
||||||
// ── Combat Math ─────────────────────────────────────────────────────────────
|
// ── Combat Math ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment) float64 {
|
// arenaDnDLevelOrZero returns the player's DnDCharacter.Level, or 0 if no
|
||||||
baseDeath := monster.BaseLethality
|
// sheet exists. Arena enters this path after combat (which migrates), so a
|
||||||
levelMod := float64(monster.ThreatLevel-char.CombatLevel) * 0.015
|
// zero return is rare and the caller's MinLevel gate handles it correctly.
|
||||||
skillMod := math.Max(0, 0.25-float64(char.CombatLevel)*0.008)
|
func arenaDnDLevelOrZero(userID id.UserID) int {
|
||||||
|
c, err := LoadDnDCharacter(userID)
|
||||||
// Average equipment tier → up to 15% reduction at max gear (tier 5 avg = 0.15)
|
if err != nil || c == nil {
|
||||||
var totalTier float64
|
return 0
|
||||||
count := 0
|
|
||||||
for _, slot := range allSlots {
|
|
||||||
if eq, ok := equip[slot]; ok {
|
|
||||||
totalTier += float64(eq.Tier)
|
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
}
|
return c.Level
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 {
|
func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 {
|
||||||
@@ -860,16 +841,11 @@ func (p *AdventurePlugin) arenaCountdown(userID id.UserID, run *ArenaRun) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
char, err := loadAdvCharacter(userID)
|
// Level gate for next tier — uses DnDCharacter.Level (post-L2 arena
|
||||||
if err != nil {
|
// is on the D&D level scale).
|
||||||
slog.Error("arena: failed to load character for auto-advance", "user", userID, "err", err)
|
playerLevel := arenaDnDLevelOrZero(userID)
|
||||||
p.arenaProcessBail(userID, freshRun)
|
if playerLevel < nextTier.MinLevel {
|
||||||
return
|
p.SendDM(userID, renderArenaLevelGate(nextTier, playerLevel)+"\n\nYour accumulated rewards have been paid out.")
|
||||||
}
|
|
||||||
|
|
||||||
// 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.")
|
|
||||||
p.arenaProcessBail(userID, freshRun)
|
p.arenaProcessBail(userID, freshRun)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type ArenaMonster struct {
|
|||||||
var arenaTiers = [5]ArenaTier{
|
var arenaTiers = [5]ArenaTier{
|
||||||
// Tier 1 — Scrubs
|
// 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,
|
BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10,
|
||||||
Monsters: [4]ArenaMonster{
|
Monsters: [4]ArenaMonster{
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ var arenaTiers = [5]ArenaTier{
|
|||||||
},
|
},
|
||||||
// Tier 2 — Thugs
|
// 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,
|
BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25,
|
||||||
Monsters: [4]ArenaMonster{
|
Monsters: [4]ArenaMonster{
|
||||||
{
|
{
|
||||||
@@ -84,7 +84,7 @@ var arenaTiers = [5]ArenaTier{
|
|||||||
},
|
},
|
||||||
// Tier 3 — Brutes
|
// 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,
|
BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60,
|
||||||
Monsters: [4]ArenaMonster{
|
Monsters: [4]ArenaMonster{
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ var arenaTiers = [5]ArenaTier{
|
|||||||
},
|
},
|
||||||
// Tier 4 — Horrors
|
// 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,
|
BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120,
|
||||||
Monsters: [4]ArenaMonster{
|
Monsters: [4]ArenaMonster{
|
||||||
{
|
{
|
||||||
@@ -143,7 +143,7 @@ var arenaTiers = [5]ArenaTier{
|
|||||||
},
|
},
|
||||||
// Tier 5 — World Eaters
|
// 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,
|
BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250,
|
||||||
Monsters: [4]ArenaMonster{
|
Monsters: [4]ArenaMonster{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 ────────────────────────────────────────────────────
|
// ── Reward Formula Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestArenaRoundReward(t *testing.T) {
|
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 ────────────────────────────────────────────────────────────
|
// ── Render Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRenderArenaStreakEntry(t *testing.T) {
|
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)
|
tier := arenaGetTier(1)
|
||||||
monster := arenaGetMonster(1, 1)
|
monster := arenaGetMonster(1, 1)
|
||||||
|
|
||||||
@@ -603,8 +485,8 @@ func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) {
|
func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) {
|
||||||
// T5 requires level 70, so use level 70+ to unlock everything
|
// T5 requires level 18 (post-L2 D&D scale); level 20 unlocks everything.
|
||||||
char := &DnDCharacter{Level: 70}
|
char := &DnDCharacter{Level: 20}
|
||||||
tier := arenaGetTier(1)
|
tier := arenaGetTier(1)
|
||||||
monster := arenaGetMonster(1, 1)
|
monster := arenaGetMonster(1, 1)
|
||||||
|
|
||||||
@@ -780,11 +662,11 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderArenaLevelGate(t *testing.T) {
|
func TestRenderArenaLevelGate(t *testing.T) {
|
||||||
tier := arenaGetTier(3)
|
tier := arenaGetTier(3)
|
||||||
text := renderArenaLevelGate(tier, 10)
|
text := renderArenaLevelGate(tier, 5)
|
||||||
if !strings.Contains(text, "Level 25") {
|
if !strings.Contains(text, "Level 8") {
|
||||||
t.Error("gate message should show required level")
|
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")
|
t.Error("gate message should show player level")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user