You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
354 lines
9.0 KiB
Go
354 lines
9.0 KiB
Go
package game
|
|
|
|
import (
|
|
"math/rand"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
"github.com/denisovdennis/autohero/internal/tuning"
|
|
)
|
|
|
|
func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
|
|
enemy := &model.Enemy{
|
|
Slug: "orc",
|
|
Archetype: "orc",
|
|
Attack: 12,
|
|
Speed: 1.0,
|
|
SpecialAbilities: []model.SpecialAbility{model.AbilityBurst},
|
|
}
|
|
|
|
// Attacks 1 and 2 should have multiplier 1.0
|
|
m1 := EnemyAttackDamageMultiplier(enemy)
|
|
if m1 != 1.0 {
|
|
t.Fatalf("attack 1: expected multiplier 1.0, got %.2f", m1)
|
|
}
|
|
m2 := EnemyAttackDamageMultiplier(enemy)
|
|
if m2 != 1.0 {
|
|
t.Fatalf("attack 2: expected multiplier 1.0, got %.2f", m2)
|
|
}
|
|
|
|
// Attack 3 should deal 1.5x
|
|
m3 := EnemyAttackDamageMultiplier(enemy)
|
|
if m3 != 1.5 {
|
|
t.Fatalf("attack 3: expected multiplier 1.5, got %.2f", m3)
|
|
}
|
|
|
|
// Attack 4 back to normal
|
|
m4 := EnemyAttackDamageMultiplier(enemy)
|
|
if m4 != 1.0 {
|
|
t.Fatalf("attack 4: expected multiplier 1.0, got %.2f", m4)
|
|
}
|
|
}
|
|
|
|
func TestLightningTitanChainLightning(t *testing.T) {
|
|
enemy := &model.Enemy{
|
|
Slug: "titan",
|
|
Archetype: "titan",
|
|
Attack: 30,
|
|
Speed: 1.5,
|
|
SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning},
|
|
}
|
|
|
|
// Attacks 1-5 should be normal (no chain lightning)
|
|
for i := 1; i <= 5; i++ {
|
|
m := EnemyAttackDamageMultiplier(enemy)
|
|
if m != 1.0 {
|
|
t.Fatalf("attack %d: expected multiplier 1.0, got %.2f", i, m)
|
|
}
|
|
}
|
|
|
|
// Attack 6 triggers chain lightning (3x)
|
|
m6 := EnemyAttackDamageMultiplier(enemy)
|
|
if m6 != 3.0 {
|
|
t.Fatalf("attack 6: expected multiplier 3.0, got %.2f", m6)
|
|
}
|
|
}
|
|
|
|
func TestIceGuardianAppliesIceSlow(t *testing.T) {
|
|
hero := &model.Hero{
|
|
ID: 1, HP: 100, MaxHP: 100,
|
|
Attack: 10, Defense: 5, Speed: 1.0,
|
|
Strength: 5, Constitution: 5, Agility: 5,
|
|
}
|
|
enemy := &model.Enemy{
|
|
Slug: "ice_guardian",
|
|
Archetype: "element",
|
|
Attack: 14,
|
|
Defense: 15,
|
|
Speed: 0.7,
|
|
SpecialAbilities: []model.SpecialAbility{model.AbilityIceSlow},
|
|
}
|
|
|
|
now := time.Now()
|
|
applied := false
|
|
for i := 0; i < 200; i++ {
|
|
hero.HP = hero.MaxHP
|
|
hero.Debuffs = nil
|
|
ProcessEnemyAttack(hero, enemy, now)
|
|
for _, d := range hero.Debuffs {
|
|
if d.Debuff.Type == model.DebuffIceSlow {
|
|
applied = true
|
|
if d.Debuff.Magnitude != 0.20 {
|
|
t.Fatalf("IceSlow magnitude should be 0.20, got %.2f", d.Debuff.Magnitude)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if applied {
|
|
break
|
|
}
|
|
}
|
|
if !applied {
|
|
t.Fatal("Ice Guardian never applied IceSlow debuff in 200 attacks")
|
|
}
|
|
}
|
|
|
|
func TestSkeletonKingSummonDamage(t *testing.T) {
|
|
hero := &model.Hero{
|
|
ID: 1, HP: 100, MaxHP: 100,
|
|
}
|
|
enemy := &model.Enemy{
|
|
Slug: "skeleton_king",
|
|
Archetype: "skeleton_king",
|
|
Attack: 18,
|
|
SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon},
|
|
}
|
|
|
|
start := time.Now()
|
|
cfg := tuning.Get()
|
|
cycleSec := cfg.SummonCycleSeconds
|
|
if cycleSec < 1 {
|
|
cycleSec = tuning.DefaultValues().SummonCycleSeconds
|
|
}
|
|
cycle := time.Duration(cycleSec) * time.Second
|
|
// Before first cycle: no summon damage.
|
|
dmg := ProcessSummonDamage(hero, enemy, start, start, start.Add(cycle/2))
|
|
if dmg != 0 {
|
|
t.Fatalf("expected no summon damage before first cycle, got %d", dmg)
|
|
}
|
|
|
|
dmg = ProcessSummonDamage(hero, enemy, start, start, start.Add(cycle))
|
|
if dmg == 0 {
|
|
t.Fatal("expected summon damage after first cycle boundary")
|
|
}
|
|
div := cfg.SummonDamageDivisor
|
|
if div < 1 {
|
|
div = tuning.DefaultValues().SummonDamageDivisor
|
|
}
|
|
expectedDmg := max(1, enemy.Attack/int(div))
|
|
if dmg != expectedDmg {
|
|
t.Fatalf("expected summon damage %d, got %d", expectedDmg, dmg)
|
|
}
|
|
}
|
|
|
|
func TestLootGenerationOnEnemyDeath(t *testing.T) {
|
|
v := tuning.DefaultValues()
|
|
v.GoldDropChance = 1.0
|
|
tuning.Set(v)
|
|
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
|
|
|
|
drops := model.GenerateLoot("wolf", 1.0)
|
|
if len(drops) == 0 {
|
|
t.Fatal("expected at least one loot drop (gold)")
|
|
}
|
|
|
|
hasGold := false
|
|
for _, d := range drops {
|
|
if d.ItemType == "gold" {
|
|
hasGold = true
|
|
if d.GoldAmount <= 0 {
|
|
t.Fatal("gold drop should have positive amount")
|
|
}
|
|
}
|
|
}
|
|
if !hasGold {
|
|
t.Fatal("expected gold drop from GenerateLoot when GoldDropChance is 1")
|
|
}
|
|
}
|
|
|
|
func TestLuckMultiplierWithBuff(t *testing.T) {
|
|
now := time.Now()
|
|
hero := &model.Hero{
|
|
Buffs: []model.ActiveBuff{{
|
|
Buff: mustBuffDef(model.BuffLuck),
|
|
AppliedAt: now.Add(-time.Second),
|
|
ExpiresAt: now.Add(10 * time.Second),
|
|
}},
|
|
}
|
|
|
|
want := tuning.Get().LuckBuffMultiplier
|
|
mult := LuckMultiplier(hero, now)
|
|
if mult != want {
|
|
t.Fatalf("expected luck multiplier %.4f, got %.4f", want, mult)
|
|
}
|
|
}
|
|
|
|
func TestLuckMultiplierWithoutBuff(t *testing.T) {
|
|
hero := &model.Hero{}
|
|
mult := LuckMultiplier(hero, time.Now())
|
|
if mult != 1.0 {
|
|
t.Fatalf("expected luck multiplier 1.0 without buff, got %.1f", mult)
|
|
}
|
|
}
|
|
|
|
func TestProcessDebuffDamageAppliesPoison(t *testing.T) {
|
|
now := time.Now()
|
|
hero := &model.Hero{
|
|
HP: 100, MaxHP: 100,
|
|
Debuffs: []model.ActiveDebuff{{
|
|
Debuff: mustDebuffDef(model.DebuffPoison),
|
|
AppliedAt: now.Add(-time.Second),
|
|
ExpiresAt: now.Add(4 * time.Second),
|
|
}},
|
|
}
|
|
|
|
dmg := ProcessDebuffDamage(hero, time.Second, now)
|
|
if dmg == 0 {
|
|
t.Fatal("expected poison to deal damage over time")
|
|
}
|
|
if hero.HP >= 100 {
|
|
t.Fatal("expected hero HP to decrease from poison")
|
|
}
|
|
}
|
|
|
|
func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
|
|
now := time.Now()
|
|
hero := &model.Hero{
|
|
ID: 1, HP: 100, MaxHP: 100,
|
|
Attack: 50, Defense: 0, Speed: 1.0,
|
|
Strength: 10, Agility: 5,
|
|
}
|
|
enemy := &model.Enemy{
|
|
Slug: "skeleton_archer",
|
|
Archetype: "skeleton",
|
|
MaxHP: 1000,
|
|
HP: 1000,
|
|
Attack: 10,
|
|
Defense: 0,
|
|
SpecialAbilities: []model.SpecialAbility{model.AbilityDodge},
|
|
}
|
|
|
|
dodged := false
|
|
for i := 0; i < 200; i++ {
|
|
enemy.HP = enemy.MaxHP
|
|
evt := ProcessAttack(hero, enemy, now)
|
|
if evt.Damage == 0 {
|
|
dodged = true
|
|
break
|
|
}
|
|
}
|
|
if !dodged {
|
|
t.Fatal("expected at least one dodge in 200 hero attacks against Skeleton Archer")
|
|
}
|
|
}
|
|
|
|
func TestCritChanceCapsApply(t *testing.T) {
|
|
orig := tuning.Get()
|
|
t.Cleanup(func() {
|
|
tuning.Set(orig)
|
|
})
|
|
cfg := tuning.DefaultValues()
|
|
cfg.HeroCritChanceCap = 0.10
|
|
cfg.EnemyCritChanceCap = 0.20
|
|
cfg.EnemyCriticalMinChance = 0.0
|
|
cfg.HeroBlockChancePerDefense = 0.0
|
|
cfg.HeroBlockChanceCap = 0.0
|
|
tuning.Set(cfg)
|
|
|
|
hero := &model.Hero{
|
|
ID: 1, HP: 100, MaxHP: 100,
|
|
Attack: 20, Defense: 0, Speed: 1.0,
|
|
Strength: 5, Agility: 5,
|
|
Gear: map[model.EquipmentSlot]*model.GearItem{
|
|
model.SlotMainHand: {CritChance: 0.9, StatType: "attack"},
|
|
},
|
|
}
|
|
enemy := &model.Enemy{
|
|
MaxHP: 100,
|
|
HP: 100,
|
|
Attack: 10,
|
|
Defense: 0,
|
|
Speed: 1.0,
|
|
}
|
|
|
|
rand.Seed(1)
|
|
heroEvt := ProcessAttack(hero, enemy, time.Now())
|
|
if heroEvt.IsCrit {
|
|
t.Fatalf("expected hero crit to be capped off, got crit")
|
|
}
|
|
|
|
rand.Seed(1)
|
|
enemy.CritChance = 0.9
|
|
enemyEvt := ProcessEnemyAttack(hero, enemy, time.Now())
|
|
if enemyEvt.IsCrit {
|
|
t.Fatalf("expected enemy crit to be capped off, got crit")
|
|
}
|
|
}
|
|
|
|
func TestDamageRollAppliesRange(t *testing.T) {
|
|
orig := tuning.Get()
|
|
t.Cleanup(func() {
|
|
tuning.Set(orig)
|
|
})
|
|
cfg := tuning.DefaultValues()
|
|
cfg.CombatDamageScale = 1.0
|
|
cfg.CombatDamageRollMin = 0.5
|
|
cfg.CombatDamageRollMax = 0.5
|
|
tuning.Set(cfg)
|
|
|
|
rand.Seed(1)
|
|
breakdown := calculateDamageBreakdown(10, 0, 0, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax)
|
|
if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 {
|
|
t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage)
|
|
}
|
|
}
|
|
|
|
func TestCalculateIncomingDamage_ShieldAndWeaken(t *testing.T) {
|
|
now := time.Now()
|
|
shield := model.ActiveBuff{
|
|
Buff: mustBuffDef(model.BuffShield),
|
|
AppliedAt: now,
|
|
ExpiresAt: now.Add(time.Minute),
|
|
}
|
|
weaken := model.ActiveDebuff{
|
|
Debuff: mustDebuffDef(model.DebuffWeaken),
|
|
AppliedAt: now,
|
|
ExpiresAt: now.Add(time.Minute),
|
|
}
|
|
|
|
raw := 90
|
|
shMag := mustBuffDef(model.BuffShield).Magnitude
|
|
wantShield := int(float64(raw) * (1 - shMag))
|
|
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, nil, now); got != wantShield {
|
|
t.Fatalf("shield: want %d got %d", wantShield, got)
|
|
}
|
|
|
|
wkMag := mustDebuffDef(model.DebuffWeaken).Magnitude
|
|
wantWeaken := int(float64(raw) * (1 + wkMag))
|
|
if got := CalculateIncomingDamage(raw, nil, []model.ActiveDebuff{weaken}, now); got != wantWeaken {
|
|
t.Fatalf("weaken: want %d got %d", wantWeaken, got)
|
|
}
|
|
|
|
wantBoth := int(float64(raw) * (1 - shMag) * (1 + wkMag))
|
|
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, []model.ActiveDebuff{weaken}, now); got != wantBoth {
|
|
t.Fatalf("shield+weaken: want %d got %d", wantBoth, got)
|
|
}
|
|
}
|
|
|
|
func mustBuffDef(bt model.BuffType) model.Buff {
|
|
b, ok := model.BuffDefinition(bt)
|
|
if !ok {
|
|
panic("missing buff def: " + string(bt))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func mustDebuffDef(dt model.DebuffType) model.Debuff {
|
|
d, ok := model.DebuffDefinition(dt)
|
|
if !ok {
|
|
panic("missing debuff def: " + string(dt))
|
|
}
|
|
return d
|
|
}
|