Test: BossAISystemTests — cover the previously Play-only boss brain
7 plain-Entities tests: phase-1/2 HP gating, expedition-only targeting (idle on no valid target), telegraphed radial-slam AoE + radius boundary, the B4 lunge-vs-slam windup disambiguation (a lunge elapse must not slam), and knockback immunity. Values pinned to Tuning.Boss* so they track tuning. Closes the last missing-coverage gap from the audit. 466/466 EditMode tests pass (7 new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode coverage for the server-only BossAISystem (the sole boss mover/attacker, previously
|
||||
/// Play-only). Exercises phase gating, expedition-only targeting, the telegraphed radial-slam AoE + its radius
|
||||
/// boundary, the B4 lunge-vs-slam windup disambiguation (a naive shared-windup reuse would slam on a lunge
|
||||
/// elapse), and knockback immunity. Values are pinned to Tuning.Boss* so the tests track tuning, not literals.
|
||||
/// No PhysicsWorldSingleton is created -> sweep=false -> the boss moves/lands unswept (headless-safe).
|
||||
/// </summary>
|
||||
public class BossAISystemTests
|
||||
{
|
||||
static Entity MakeBoss(EntityManager em, float3 pos, float cur, float max,
|
||||
byte pending = 0, uint windup = 0, uint slamReady = 0, uint lungeReady = 0, uint knockUntil = 0)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(EnemyTag), typeof(BossState), typeof(LocalTransform), typeof(EnemyStats),
|
||||
typeof(Health), typeof(AttackWindup), typeof(KnockbackState), typeof(LungeState));
|
||||
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.SetComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 2f, AttackDamage = 10f, AttackCooldownTicks = 30 });
|
||||
em.SetComponentData(e, new Health { Current = cur, Max = max });
|
||||
em.SetComponentData(e, new BossState { Phase = 1, PendingAttack = pending, SlamReadyTick = slamReady, LungeReadyTick = lungeReady });
|
||||
em.SetComponentData(e, new AttackWindup { WindUpUntilTick = windup });
|
||||
em.SetComponentData(e, new KnockbackState { UntilTick = knockUntil });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos, byte region = RegionId.Expedition, float hp = 100f)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(PlayerTag), typeof(RegionTag), typeof(LocalTransform), typeof(Health), typeof(DamageEvent));
|
||||
em.SetComponentData(e, new RegionTag { Region = region });
|
||||
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.SetComponentData(e, new Health { Current = hp, Max = hp });
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Phase_One_When_Above_Half_HP()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_P1", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var boss = MakeBoss(em, float3.zero, cur: 60f, max: 100f);
|
||||
MakePlayer(em, new float3(3f, 0f, 0f));
|
||||
group.Update();
|
||||
Assert.AreEqual((byte)1, em.GetComponentData<BossState>(boss).Phase, "Above the phase-2 fraction -> phase 1.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Phase_Two_At_Or_Below_Half_HP()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_P2", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
float cur = 100f * Tuning.BossPhase2HealthFraction; // exactly the boundary
|
||||
var boss = MakeBoss(em, float3.zero, cur: cur, max: 100f);
|
||||
MakePlayer(em, new float3(3f, 0f, 0f));
|
||||
group.Update();
|
||||
Assert.AreEqual((byte)2, em.GetComponentData<BossState>(boss).Phase, "At/below the fraction -> phase 2.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Living_Expedition_Target_Leaves_Boss_Idle()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_NoTgt", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f);
|
||||
MakePlayer(em, new float3(3f, 0f, 0f), region: RegionId.Base); // wrong region
|
||||
MakePlayer(em, new float3(4f, 0f, 0f), region: RegionId.Expedition, hp: 0f); // dead
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(float3.zero, em.GetComponentData<LocalTransform>(boss).Position, "No valid target -> the boss does not move.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "No target -> no telegraph.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Slam_Lands_Radial_AoE_On_Windup_Elapse()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Slam", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); // 100 <= 200 -> elapsed
|
||||
var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius
|
||||
|
||||
group.Update();
|
||||
|
||||
var dmg = em.GetBuffer<DamageEvent>(player);
|
||||
Assert.AreEqual(1, dmg.Length, "A player inside the slam radius takes one hit on landing.");
|
||||
Assert.AreEqual(Tuning.BossSlamDamage, dmg[0].Amount, 1e-3f);
|
||||
Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Slam damage is sourced from the boss/environment (-1).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "Windup cleared after landing.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Slam_Misses_A_Player_Outside_The_Radius()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_SlamMiss", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100);
|
||||
var player = MakePlayer(em, new float3(Tuning.BossSlamRadius + 1f, 0f, 0f)); // just outside
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "Outside the slam radius takes no hit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lunge_Windup_Elapse_Commits_Travel_And_Does_Not_Slam()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Lunge", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 1, windup: 100); // PendingAttack=1 => lunge
|
||||
var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius, but a LUNGE must not slam
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "A lunge elapse must NOT deal slam damage (B4 disambiguation).");
|
||||
Assert.AreEqual(Tuning.BossLungeSpeed, em.GetComponentData<LungeState>(boss).Speed, 1e-3f, "Lunge travel committed.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "Windup cleared on lunge commit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Knockback_Residual_Is_Zeroed_Boss_Is_Immune()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Knock", tick: 200, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, knockUntil: 999u);
|
||||
MakePlayer(em, new float3(3f, 0f, 0f));
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<KnockbackState>(boss).UntilTick, "The boss is knockback-immune: residual is zeroed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 407138571a76c6f4f821fcbde92327a5
|
||||
Reference in New Issue
Block a user