package model import ( "math" "testing" "time" ) 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, }, SlotChest: { PrimaryStat: 4, SpeedModifier: 0.7, AgilityBonus: -3, }, }, } gotAttack := hero.EffectiveAttackAt(now) // atk = 10 + 10*2 + 3/4 + 8/8 = 31 + weapon.PrimaryStat(5) = 36 if gotAttack != 36 { t.Fatalf("expected attack 36, got %d", gotAttack) } gotDefense := hero.EffectiveDefenseAt(now) // def = 5 + 8/4 + 3/8 = 7 + chest.PrimaryStat(4) = 11 if gotDefense != 11 { t.Fatalf("expected defense 11, got %d", gotDefense) } gotSpeed := hero.EffectiveSpeedAt(now) wantSpeed := (1.0 + 3*AgilityCoef) * 1.3 * 0.7 if math.Abs(gotSpeed-wantSpeed) > 0.001 { t.Fatalf("expected speed %.3f, got %.3f", wantSpeed, gotSpeed) } } 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: DefaultBuffs[BuffRage], AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }, { Buff: DefaultBuffs[BuffWarCry], AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }, { Buff: DefaultBuffs[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: DefaultBuffs[BuffWarCry], AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(10 * time.Second), }, }, } got := hero.EffectiveSpeedAt(now) 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: DefaultBuffs[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: DefaultBuffs[BuffRush], AppliedAt: now.Add(-time.Second), ExpiresAt: now.Add(5 * time.Second), }} got := hero.MovementSpeedMultiplier(now) want := 1.5 if math.Abs(got-want) > 0.001 { t.Fatalf("expected Rush to give movement multiplier %.1f, 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: DefaultDebuffs[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: DefaultDebuffs[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: DefaultDebuffs[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 != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 { t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) } if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 6 { 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 != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 { t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) } if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 6 { t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) } }) } func TestXPToNextLevelFormula(t *testing.T) { if got := XPToNextLevel(1); got != 180 { t.Fatalf("XPToNextLevel(1) = %d, want 180", got) } if got := XPToNextLevel(2); got != 230 { t.Fatalf("XPToNextLevel(2) = %d, want 230", got) } if got := XPToNextLevel(10); got != 1450 { t.Fatalf("XPToNextLevel(10) = %d, want 1450", got) } if got := XPToNextLevel(30); got != 23000 { t.Fatalf("XPToNextLevel(30) = %d, want 23000", 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) } }