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:
2026-07-08 13:03:38 -07:00
parent 52eda31360
commit b48fe5250c
2 changed files with 161 additions and 0 deletions
@@ -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