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 enemy KNOCKBACK (server-only, no re-bake). Two halves: /// ProjectileDamageSystem STAMPS a on a hit Husk (Dir = the projectile heading, /// UntilTick = now + Tuning.KnockbackDurationTicks); EnemyAISystem APPLIES it — moving the Husk along the /// knockback heading (overriding seek) and suppressing its strike for the window, then resuming seek. Both /// systems are server-only Burst ISystems; a NetworkTime singleton is seeded (TurretFireSystems pattern). /// Knockback is gated by Tuning.KnockbackSpeed (0 disables) — so ProjectileDamageSystem only stamps when > 0. /// public class KnockbackTests { 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) }); } [Test] public void ProjectileDamage_Stamps_Knockback_On_Hit_Husk() { var world = new World("KnockbackStampWorld"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); using (world) { var em = world.EntityManager; SetServerTick(world, 200); var husk = em.CreateEntity(); em.AddComponentData(husk, LocalTransform.FromPosition(new float3(0, 0, 5))); em.AddComponentData(husk, new HitRadius { Value = 0.8f }); em.AddComponentData(husk, new Health { Current = 50f, Max = 50f }); em.AddBuffer(husk); em.AddComponentData(husk, new KnockbackState()); // baked on real Husks var proj = em.CreateEntity(); em.AddComponentData(proj, LocalTransform.FromPosition(new float3(0, 0, 5))); em.AddComponentData(proj, new Projectile { Direction = new float2(0, 1), Speed = 10f, Damage = 20f, Range = 20f, DistanceTravelled = 5f }); em.AddComponentData(proj, new GhostOwner { NetworkId = 1 }); world.SetTime(new TimeData(elapsedTime: 0.1f, deltaTime: 0.1f)); group.Update(); Assert.AreEqual(1, em.GetBuffer(husk).Length, "The hit still deals damage."); Assert.IsFalse(em.Exists(proj), "The projectile is consumed on hit."); var kb = em.GetComponentData(husk); Assert.AreEqual(TickUtil.NonZero(200 + (uint)Tuning.KnockbackDurationTicks), kb.UntilTick, "Knockback is scheduled until now + KnockbackDurationTicks."); Assert.AreEqual(0f, kb.Dir.x, 1e-4f); Assert.AreEqual(1f, kb.Dir.y, 1e-4f, "Knockback heading matches the projectile direction."); Assert.AreEqual(Tuning.KnockbackSpeed, kb.Speed, 1e-4f); } } static (World world, SimulationSystemGroup group) MakeAiWorld(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 MakeHusk(EntityManager em, float3 pos, KnockbackState kb) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 }); em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 }); em.AddComponentData(e, kb); em.AddComponentData(e, new AttackWindup()); em.AddComponent(e); return e; } [Test] public void Knockback_Overrides_Seek_Then_Resumes() { var (world, group) = MakeAiWorld("KnockbackAiWorld", 200); using (world) { var em = world.EntityManager; MakePlayer(em, new float3(10, 1, 0)); // player at +X var husk = MakeHusk(em, new float3(5, 1, 0), new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) }); group.Update(); // tick 200: knocked -> moves -X (against the seek toward +X) float xKnocked = em.GetComponentData(husk).Position.x; Assert.Less(xKnocked, 5f, "While knocked the Husk moves along the knockback heading (-X), not toward the player (+X)."); SetServerTick(world, 208); group.Update(); // window elapsed -> seek resumes toward the player at +X float xResumed = em.GetComponentData(husk).Position.x; Assert.Greater(xResumed, xKnocked, "Once the knockback window elapses the Husk seeks back toward the player."); Assert.AreEqual(0u, em.GetComponentData(husk).UntilTick, "Knockback state is cleared after the window."); } } [Test] public void Knocked_Husk_Does_Not_Strike() { var (world, group) = MakeAiWorld("KnockbackNoStrikeWorld", 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, new float3(10, 1, 0)); // Husk inside AttackRange of the player, but knocked. MakeHusk(em, new float3(9, 1, 0), new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) }); group.Update(); Assert.AreEqual(0, em.GetBuffer(player).Length, "A recoiling Husk does not strike even when inside AttackRange."); } } } }