package model import ( "math" "testing" "time" "github.com/denisovdennis/autohero/internal/tuning" ) func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) { now := time.Now() hero := &Hero{ Attack: 10, Defense: 5, Speed: 1.0, Strength: 10, Constitution: 8, Agility: 6, Gear: map[EquipmentSlot]*GearItem{ SlotMainHand: { PrimaryStat: 5, SpeedModifier: 1.3, StatType: "attack", }, SlotChest: { PrimaryStat: 4, SpeedModifier: 0.7, AgilityBonus: -3, StatType: "defense", }, }, } gotAttack := hero.EffectiveAttackAt(now) // atk = 10 + 10*2 + 3/4 = 30 + weapon.PrimaryStat(5) = 35 if gotAttack != 35 { t.Fatalf("expected attack 35, got %d", gotAttack) } gotDefense := hero.EffectiveDefenseAt(now) // def = 5 + 8 + 3/4 = 13 + chest.PrimaryStat(4) = 17 if gotDefense != 17 { t.Fatalf("expected defense 17, got %d", gotDefense) } gotSpeed := hero.EffectiveSpeedAt(now) wantSpeed := (1.0 + 3*tuning.DefaultValues().AgilityCoef) * 1.3 * 0.7 if math.Abs(gotSpeed-wantSpeed) > 0.001 { t.Fatalf("expected speed %.3f, got %.3f", wantSpeed, gotSpeed) } } func TestGearPrimaryBonusesAcrossSlots(t *testing.T) { now := time.Now() hero := &Hero{ Attack: 10, Defense: 5, Speed: 1.0, Strength: 2, Constitution: 3, Agility: 4, Gear: map[EquipmentSlot]*GearItem{ SlotMainHand: {PrimaryStat: 6, StatType: "attack"}, SlotHead: {PrimaryStat: 4, StatType: "defense"}, SlotChest: {PrimaryStat: 7, StatType: "defense"}, SlotFinger: {PrimaryStat: 5, StatType: "mixed"}, }, } if got := hero.EffectiveAttackAt(now); got != 23 { t.Fatalf("expected attack 23, got %d", got) } if got := hero.EffectiveDefenseAt(now); got != 23 { t.Fatalf("expected defense 23, got %d", got) } } func TestBuffsProvideTemporaryStatEffects(t *testing.T) { now := time.Now() hero := &Hero{ Attack: 10, Defense: 5, Speed: 1.0, Strength: 10, Constitution: 8, Agility: 6, Buffs: []ActiveBuff{ { Buff: mustBuffDef(BuffRage), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }, { Buff: mustBuffDef(BuffWarCry), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }, { Buff: mustBuffDef(BuffShield), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }, }, } if hero.EffectiveAttackAt(now) <= 30 { t.Fatalf("expected buffed attack to increase above baseline") } if hero.EffectiveDefenseAt(now) <= 5 { t.Fatalf("expected shield constitution bonus to increase defense") } if hero.EffectiveSpeedAt(now) <= 1.0 { t.Fatalf("expected war cry to increase attack speed") } } func TestEffectiveSpeedIsCapped(t *testing.T) { now := time.Now() hero := &Hero{ Speed: 2.5, Agility: 200, Gear: map[EquipmentSlot]*GearItem{ SlotMainHand: {SpeedModifier: 1.5}, }, Buffs: []ActiveBuff{ { Buff: mustBuffDef(BuffWarCry), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(10 * time.Second), }, }, } got := hero.EffectiveSpeedAt(now) maxAttackSpeed := tuning.DefaultValues().MaxAttackSpeed if got != maxAttackSpeed { t.Fatalf("expected speed cap %.1f, got %.3f", maxAttackSpeed, got) } } func TestRushDoesNotAffectAttackSpeed(t *testing.T) { now := time.Now() hero := &Hero{Speed: 1.0, Agility: 5} baseSpeed := hero.EffectiveSpeedAt(now) hero.Buffs = []ActiveBuff{{ Buff: mustBuffDef(BuffRush), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }} buffedSpeed := hero.EffectiveSpeedAt(now) if math.Abs(buffedSpeed-baseSpeed) > 0.001 { t.Fatalf("Rush should not affect attack speed: base=%.3f, buffed=%.3f", baseSpeed, buffedSpeed) } } func TestRushAffectsMovementSpeed(t *testing.T) { now := time.Now() hero := &Hero{Speed: 1.0, Agility: 5} baseMoveSpeed := hero.MovementSpeedMultiplier(now) if baseMoveSpeed != 1.0 { t.Fatalf("expected base movement multiplier 1.0, got %.3f", baseMoveSpeed) } hero.Buffs = []ActiveBuff{{ Buff: mustBuffDef(BuffRush), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }} got := hero.MovementSpeedMultiplier(now) want := 1.0 + mustBuffDef(BuffRush).Magnitude if math.Abs(got-want) > 0.001 { t.Fatalf("expected Rush movement multiplier %.3f, got %.3f", want, got) } } func TestSlowDoesNotAffectAttackSpeed(t *testing.T) { now := time.Now() hero := &Hero{Speed: 1.0, Agility: 5} baseSpeed := hero.EffectiveSpeedAt(now) hero.Debuffs = []ActiveDebuff{{ Debuff: mustDebuffDef(DebuffSlow), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(3 * time.Second), }} debuffedSpeed := hero.EffectiveSpeedAt(now) if math.Abs(debuffedSpeed-baseSpeed) > 0.001 { t.Fatalf("Slow should not affect attack speed: base=%.3f, debuffed=%.3f", baseSpeed, debuffedSpeed) } } func TestSlowAffectsMovementSpeed(t *testing.T) { now := time.Now() hero := &Hero{Speed: 1.0, Agility: 5} hero.Debuffs = []ActiveDebuff{{ Debuff: mustDebuffDef(DebuffSlow), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(3 * time.Second), }} got := hero.MovementSpeedMultiplier(now) want := 0.6 // 1.0 * (1 - 0.4) if math.Abs(got-want) > 0.001 { t.Fatalf("expected Slow to give movement multiplier %.1f, got %.3f", want, got) } } func TestIceSlowReducesAttackSpeed(t *testing.T) { now := time.Now() hero := &Hero{Speed: 1.0, Agility: 5} baseSpeed := hero.EffectiveSpeedAt(now) hero.Debuffs = []ActiveDebuff{{ Debuff: mustDebuffDef(DebuffIceSlow), AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(3 * time.Second), }} debuffedSpeed := hero.EffectiveSpeedAt(now) want := baseSpeed * 0.8 if math.Abs(debuffedSpeed-want) > 0.01 { t.Fatalf("IceSlow should reduce attack speed by 20%%: expected %.3f, got %.3f", want, debuffedSpeed) } } func TestLevelUpParity(t *testing.T) { hero1 := &Hero{ Level: 1, XP: 500, MaxHP: 100, HP: 100, Attack: 10, Defense: 5, Speed: 1.0, Strength: 5, Constitution: 5, Agility: 5, Luck: 5, } hero2 := *hero1 // Level up hero1 via LevelUp() levels1 := 0 for hero1.LevelUp() { levels1++ } // Level up hero2 via LevelUp() too (should be identical since both use same method) levels2 := 0 for hero2.LevelUp() { levels2++ } if levels1 != levels2 { t.Fatalf("expected same levels gained: %d vs %d", levels1, levels2) } if hero1.Level != hero2.Level || hero1.MaxHP != hero2.MaxHP || hero1.Attack != hero2.Attack || hero1.Defense != hero2.Defense { t.Fatalf("heroes diverged after identical LevelUp calls") } } // v3 progression: XP bases ×10 vs v2; secondary-stat cadences ×10 (3→30, 4→40, …). func TestProgressionV3CanonicalSnapshots(t *testing.T) { now := time.Now() snap := func(targetLevel int) *Hero { h := &Hero{ Level: 1, XP: 0, MaxHP: 100, HP: 100, Attack: 10, Defense: 5, Speed: 1.0, Strength: 1, Constitution: 1, Agility: 1, Luck: 1, } for h.Level < targetLevel { h.XP += XPToNextLevel(h.Level) if !h.LevelUp() { t.Fatalf("expected level-up before reaching target level %d", targetLevel) } } h.RefreshDerivedCombatStats(now) return h } t.Run("L30", func(t *testing.T) { h := snap(30) if h.MaxHP != 177 || h.Attack != 20 || h.Defense != 15 || h.Strength != 16 { t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) } if h.EffectiveAttackAt(now) != 56 || h.EffectiveDefenseAt(now) != 35 { t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) } }) t.Run("L45", func(t *testing.T) { h := snap(45) if h.MaxHP != 228 || h.Attack != 25 || h.Defense != 20 || h.Strength != 23 { t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) } if h.EffectiveAttackAt(now) != 76 || h.EffectiveDefenseAt(now) != 48 { t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) } }) } func TestXPToNextLevelFormula(t *testing.T) { // Early: ~100 / 150 / 225 kills at 1 XP per kill (nonlinear 1.5× per level band). if got := XPToNextLevel(1); got != 100 { t.Fatalf("XPToNextLevel(1) = %d, want 100", got) } if got := XPToNextLevel(2); got != 150 { t.Fatalf("XPToNextLevel(2) = %d, want 150", got) } if got := XPToNextLevel(3); got != 225 { t.Fatalf("XPToNextLevel(3) = %d, want 225", got) } if got := XPToNextLevel(10); got != 2947 { t.Fatalf("XPToNextLevel(10) = %d, want 2947", got) } if got := XPToNextLevel(30); got != 48232 { t.Fatalf("XPToNextLevel(30) = %d, want 48232", got) } } func TestLevelUpDoesNotRestoreHP(t *testing.T) { hero := &Hero{ Level: 1, XP: 200, HP: 40, MaxHP: 100, Attack: 10, Defense: 5, Speed: 1.0, Strength: 5, Constitution: 5, Agility: 5, Luck: 5, } if !hero.LevelUp() { t.Fatal("expected hero to level up") } if hero.HP == hero.MaxHP { t.Fatalf("level-up should NOT restore HP (spec §3.3): hp=%d max=%d", hero.HP, hero.MaxHP) } if hero.HP != 40 { t.Fatalf("HP should be unchanged after level-up: got %d, want 40", hero.HP) } } func mustBuffDef(bt BuffType) Buff { b, ok := BuffDefinition(bt) if !ok { panic("missing buff def: " + string(bt)) } return b } func mustDebuffDef(dt DebuffType) Debuff { d, ok := DebuffDefinition(dt) if !ok { panic("missing debuff def: " + string(dt)) } return d }