Files
a301_game_server/internal/combat/combat_test.go
2026-02-26 17:52:48 +09:00

240 lines
6.0 KiB
Go

package combat
import (
"testing"
"time"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// mockCombatant implements Combatant for testing.
type mockCombatant struct {
id uint64
pos mathutil.Vec3
hp, maxHP int32
mp, maxMP int32
str, dex, intStat int32
}
func (m *mockCombatant) EntityID() uint64 { return m.id }
func (m *mockCombatant) EntityType() entity.Type { return entity.TypePlayer }
func (m *mockCombatant) Position() mathutil.Vec3 { return m.pos }
func (m *mockCombatant) SetPosition(p mathutil.Vec3) { m.pos = p }
func (m *mockCombatant) Rotation() float32 { return 0 }
func (m *mockCombatant) SetRotation(float32) {}
func (m *mockCombatant) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
func (m *mockCombatant) HP() int32 { return m.hp }
func (m *mockCombatant) SetHP(hp int32) {
if hp < 0 { hp = 0 }
if hp > m.maxHP { hp = m.maxHP }
m.hp = hp
}
func (m *mockCombatant) MaxHP() int32 { return m.maxHP }
func (m *mockCombatant) MP() int32 { return m.mp }
func (m *mockCombatant) SetMP(mp int32) {
if mp < 0 { mp = 0 }
m.mp = mp
}
func (m *mockCombatant) IsAlive() bool { return m.hp > 0 }
func (m *mockCombatant) Stats() CombatStats {
return CombatStats{Str: m.str, Dex: m.dex, Int: m.intStat, Level: 1}
}
func newMock(id uint64, x, z float32) *mockCombatant {
return &mockCombatant{
id: id, pos: mathutil.NewVec3(x, 0, z),
hp: 100, maxHP: 100, mp: 100, maxMP: 100,
str: 10, dex: 10, intStat: 10,
}
}
func TestBasicAttack(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0) // within range (3.0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatalf("expected success, got error: %s", errMsg)
}
if target.HP() >= 100 {
t.Error("target should have taken damage")
}
}
func TestOutOfRange(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 100, 0) // far away
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected failure due to range")
}
}
func TestCooldown(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
getEnt := func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
}
// First use should succeed.
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if !ok {
t.Fatal("first use should succeed")
}
// Immediate second use should fail (cooldown).
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if ok {
t.Error("expected cooldown failure")
}
if errMsg != "skill on cooldown" {
t.Errorf("expected 'skill on cooldown', got '%s'", errMsg)
}
}
func TestManaConsumption(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
target := newMock(2, 5, 0)
caster.mp = 10 // not enough for Fireball (20 mana)
entities := map[uint64]*mockCombatant{1: caster, 2: target}
ok, errMsg := mgr.UseSkill(caster, 2, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected mana failure")
}
if errMsg != "not enough mana" {
t.Errorf("expected 'not enough mana', got '%s'", errMsg)
}
}
func TestHealSelf(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
caster.hp = 30
ok, _ := mgr.UseSkill(caster, 3, 0, mathutil.Vec3{},
func(id uint64) entity.Entity { return nil },
nil,
)
if !ok {
t.Fatal("heal should succeed")
}
if caster.HP() <= 30 {
t.Error("HP should have increased")
}
}
func TestPoisonDoT(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 5, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
// Apply poison (skill 5).
ok, _ := mgr.UseSkill(attacker, 5, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatal("poison should succeed")
}
initialHP := target.HP()
// Simulate ticks until first DoT tick (2s at 50ms/tick = 40 ticks).
for i := 0; i < 40; i++ {
mgr.UpdateBuffs(50*time.Millisecond, func(id uint64) Combatant {
if e, ok := entities[id]; ok { return e }
return nil
})
}
if target.HP() >= initialHP {
t.Error("poison DoT should have dealt damage")
}
}
func TestDamageFormula(t *testing.T) {
// Base 15, attacker STR 10 (1.1x), defender DEX 10 (0.95x)
// Expected ~15.675 (without crit).
result := CalcDamage(15, 10, 10)
if result.FinalDamage < 1 {
t.Error("damage should be at least 1")
}
}
func TestRespawn(t *testing.T) {
mgr := NewManager()
ent := newMock(1, 50, 50)
ent.hp = 0
spawn := mathutil.NewVec3(0, 0, 0)
mgr.Respawn(ent, spawn)
if !ent.IsAlive() {
t.Error("should be alive after respawn")
}
if ent.HP() != ent.MaxHP() {
t.Error("should have full HP after respawn")
}
if ent.Position() != spawn {
t.Error("should be at spawn position")
}
}