|
|
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)
|
|
|
}
|
|
|
}
|