using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with /// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when /// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and /// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots /// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer. /// public class ChargerTests { static void SetServerTick(World world, uint tick) { var em = world.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); } static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetServerTick(world, serverTick); return (world, group); } static Entity MakePlayer(EntityManager em, float3 pos) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponent(e); em.AddBuffer(e); return e; } static Entity MakeCharger(EntityManager em, float3 pos) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 }); em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 }); em.AddComponentData(e, new KnockbackState()); em.AddComponentData(e, new AttackWindup()); em.AddComponentData(e, new LungeState()); em.AddComponent(e); em.SetComponentEnabled(e, false); // baked DISABLED on the real Charger (spawns not-lunging) em.AddComponent(e); return e; } [Test] public void Commit_Fires_Even_When_Target_Left_Range() { var (world, group) = MakeWorld("ChargerCommit", 200); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6) var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick group.Update(); // tick 200 var lunge = em.GetComponentData(charger); Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range)."); Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X)."); Assert.AreEqual(0u, em.GetComponentData(charger).WindUpUntilTick, "The wind-up clears on commit."); } } [Test] public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window() { var (world, group) = MakeWorld("ChargerWhiff", 206); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "A whiffed lunge is cleared."); Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData(charger).NextAttackTick, "An overshoot whiff extends the attack cooldown by the stagger window (the punish window)."); using var tq = em.CreateEntityQuery(typeof(DevTelemetry)); Assert.AreEqual(1u, tq.GetSingleton().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window."); Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData(charger).StaggerUntilTick, "The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source)."); } } [Test] public void Knockback_Cancels_An_InFlight_Lunge() { var (world, group) = MakeWorld("ChargerKnockback", 305); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(10, 1, 0)); var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X group.Update(); // tick 305: knockback (until 315) wins Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Knockback cancels the in-flight lunge (no two-writer contention on Position)."); Assert.Less(em.GetComponentData(charger).Position.x, 0f, "The recoiling Charger moved along its knockback direction (-X), not its lunge direction."); } } [Test] public void Commit_Enables_IsLunging() { var (world, group) = MakeWorld("ChargerIsLungingCommit", 200); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(3, 1, 0)); var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick -> commit Assert.IsFalse(em.IsComponentEnabled(charger), "Charger spawns not-lunging (baked DISABLED)."); group.Update(); // tick 200: commit the lunge Assert.AreNotEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the lunge committed."); Assert.IsTrue(em.IsComponentEnabled(charger), "The replicated mid-lunge cue is ENABLED while a committed lunge is live (.WithPresent visits the disabled entity to write the bit)."); } } [Test] public void Whiff_Disables_IsLunging() { var (world, group) = MakeWorld("ChargerIsLungingWhiff", 206); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(-10, 1, 0)); var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring em.SetComponentEnabled(charger, true); // was mid-lunge group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the whiffed lunge cleared."); Assert.IsFalse(em.IsComponentEnabled(charger), "The cue clears the tick the lunge ends (whiff)."); } } [Test] public void Knockback_Disables_IsLunging() { var (world, group) = MakeWorld("ChargerIsLungingKnockback", 305); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(10, 1, 0)); var charger = MakeCharger(em, new float3(0, 1, 0)); em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); em.SetComponentEnabled(charger, true); // mid-lunge before the knockback group.Update(); // tick 305: knockback cancels the lunge (UntilTick -> 0) via the mid-body continue path Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: knockback cancelled the lunge."); Assert.IsFalse(em.IsComponentEnabled(charger), "The cue clears when knockback cancels the lunge (covers the mid-body continue exit path)."); } } } }