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