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 Husk attack TELEGRAPH (the 2-phase strike in EnemyAISystem). The /// strike no longer fires instantly: when a Husk is first in-range + cooldown-ready it commits a wind-up /// ( = now + Tuning.AttackWindupTicks, replicated so the client can /// cue it) and damages NOTHING; the strike lands only when the wind-up tick elapses, and leaving range /// mid-wind-up cancels it. Server timing is fully headless (the replication + client cue are the Play check). /// public class TelegraphTests { 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 MakeHusk(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 = 8f, AttackCooldownTicks = 36 }); em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 }); em.AddComponentData(e, new KnockbackState()); em.AddComponentData(e, new AttackWindup()); em.AddComponent(e); return e; } [Test] public void Husk_Winds_Up_First_Then_Strikes_At_Expiry() { var (world, group) = MakeWorld("TelegraphStrike", 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, new float3(10, 1, 0)); var husk = MakeHusk(em, new float3(9, 1, 0)); // distance 1 < AttackRange 1.6 -> in range group.Update(); // tick 200: begins the wind-up, deals NO damage yet uint expected = TickUtil.NonZero(200 + (uint)Tuning.AttackWindupTicks); Assert.AreEqual(expected, em.GetComponentData(husk).WindUpUntilTick, "An in-range, ready Husk commits a wind-up until now + AttackWindupTicks."); Assert.AreEqual(0, em.GetBuffer(player).Length, "No damage lands during the wind-up."); SetServerTick(world, expected); group.Update(); // wind-up elapsed -> strike lands Assert.AreEqual(1, em.GetBuffer(player).Length, "The strike lands exactly when the wind-up elapses."); Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The wind-up resets after the strike."); Assert.AreNotEqual(0u, em.GetComponentData(husk).NextAttackTick, "The strike cooldown is stamped."); } } [Test] public void Leaving_Range_Mid_WindUp_Cancels_The_Strike() { var (world, group) = MakeWorld("TelegraphCancel", 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, new float3(10, 1, 0)); var husk = MakeHusk(em, new float3(9, 1, 0)); group.Update(); // begins the wind-up uint windTick = em.GetComponentData(husk).WindUpUntilTick; Assert.AreNotEqual(0u, windTick); // Player flees far out of range before the wind-up completes. em.SetComponentData(player, LocalTransform.FromPosition(new float3(60, 1, 0))); SetServerTick(world, windTick); group.Update(); Assert.AreEqual(0, em.GetBuffer(player).Length, "Leaving range mid-wind-up cancels the strike."); Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The cancelled wind-up is cleared."); } } } }