using NUnit.Framework; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the MC-1 predicted DashSystem (velocity/sharpness OVERRIDE + start/cooldown) /// and the PlayerDeathStateSystem dash cleanup. The systems carry [UpdateInGroup(PredictedSimulationSystemGroup)] /// but world-system filtering is ignored when added to a group manually, so they run in this netcode-free world. /// The override/cleanup logic is fully headless; the input-driven START path uses PlayerInput.Dash.IsSet (the /// real apply-under-prediction is a Play-validation item). /// public class DashSystemTests { 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) where T : unmanaged, ISystem { 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); } // Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s. const float ExpectedDashSpeed = 20f; static Entity MakeDasher(EntityManager em, float2 facing) { var e = em.CreateEntity(); em.AddComponentData(e, new DashState()); em.AddComponentData(e, new DashCooldown { NextTick = 0 }); em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero }); em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = 15 em.AddComponentData(e, new PlayerInput()); em.AddComponentData(e, new PlayerFacing { Direction = facing }); em.AddComponent(e); // enabled by default em.AddComponent(e); em.SetComponentEnabled(e, false); // alive return e; } [Test] public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink() { var (world, group) = MakeWorld("DashOverride", 100); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 }); em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // as if PlayerControlSystem wrote input velocity group.Update(); // tick 100: i-frame window [100,112) active var ctrl = em.GetComponentData(e).MoveVelocity; Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0."); Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0."); Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20)."); Assert.AreEqual(200f, em.GetComponentData(e).GroundedMovementSharpness, 1e-3f, "i-frame window raises GroundedMovementSharpness to ~200 (the blink)."); } } [Test] public void Recovery_Tail_Locks_Movement_And_Restores_Sharpness() { var (world, group) = MakeWorld("DashRecover", 105); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 }); var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc); em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); group.Update(); // tick 105: iFrame ended (100<=105), recover active (109>105) Assert.AreEqual(0f, math.length(em.GetComponentData(e).MoveVelocity), 1e-3f, "Recovery tail locks movement to zero (the punishable window)."); Assert.AreEqual(15f, em.GetComponentData(e).GroundedMovementSharpness, 1e-3f, "Recovery tail restores sharpness to the default (crisp stop)."); } } [Test] public void After_Window_Restores_Sharpness_And_Leaves_Input_Velocity_Untouched() { var (world, group) = MakeWorld("DashRestore", 200); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 }); var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc); em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(7, 0, 0) }); // input velocity, must survive group.Update(); // tick 200: window fully elapsed Assert.AreEqual(15f, em.GetComponentData(e).GroundedMovementSharpness, 1e-3f, "Sharpness restored to default after the dash window elapses."); Assert.AreEqual(7f, em.GetComponentData(e).MoveVelocity.x, 1e-3f, "Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands)."); } } [Test] public void Dash_Starts_On_Press_When_Ready_And_Sets_Window_And_Cooldown() { var (world, group) = MakeWorld("DashStart", 100); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi); Assert.IsTrue(em.GetComponentData(e).Dash.IsSet, "Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption)."); group.Update(); // tick 100 var ds = em.GetComponentData(e); Assert.AreEqual(100u, ds.StartTick, "StartTick = now."); Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12."); Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9."); Assert.AreEqual(145u, em.GetComponentData(e).NextTick, "Cooldown = now + 45."); } } [Test] public void Dash_Start_Is_Idempotent_At_The_Same_Tick() { var (world, group) = MakeWorld("DashIdem", 100); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi); group.Update(); // starts the dash at tick 100 var first = em.GetComponentData(e); group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window) var second = em.GetComponentData(e); Assert.AreEqual(first.StartTick, second.StartTick, "Re-running the start tick must not re-trigger the dash."); Assert.AreEqual(first.IFrameUntilTick, second.IFrameUntilTick, "The window is set exactly once."); Assert.AreEqual(first.RecoverUntilTick, second.RecoverUntilTick, "The window is set exactly once."); } } [Test] public void Dash_Is_Gated_By_Cooldown_Until_It_Expires() { var (world, group) = MakeWorld("DashCooldown", 130); using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); // As if dashed at 100: window elapsed by 130, cooldown until 145. em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 }); em.SetComponentData(e, new DashCooldown { NextTick = 145 }); var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi); group.Update(); // tick 130 < cooldown 145 -> NO new dash Assert.AreEqual(100u, em.GetComponentData(e).StartTick, "On cooldown: a press does not start a new dash."); SetServerTick(world, 150); // past the cooldown pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi); group.Update(); Assert.AreEqual(150u, em.GetComponentData(e).StartTick, "Past cooldown: a press starts a new dash."); } } [Test] public void Death_Mid_Dash_Clears_Window_And_Restores_Sharpness() { var (world, group) = MakeWorld("DashDeath", 100); using (world) { var em = world.EntityManager; var e = em.CreateEntity(); em.AddComponent(e); em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead em.AddComponentData(e, new CharacterControl { MoveVelocity = new float3(3, 0, 0) }); em.AddComponentData(e, new DashState { Dir = new float2(1, 0), StartTick = 90, IFrameUntilTick = 110, RecoverUntilTick = 119 }); // in-flight var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.AddComponentData(e, cc); em.AddComponent(e); em.AddComponent(e); em.SetComponentEnabled(e, false); group.Update(); Assert.IsTrue(em.IsComponentEnabled(e), "Health<=0 derives Dead enabled."); Assert.AreEqual(0u, em.GetComponentData(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn)."); Assert.AreEqual(15f, em.GetComponentData(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness."); Assert.AreEqual(0f, math.length(em.GetComponentData(e).MoveVelocity), 1e-3f, "Death zeroes movement."); } } [Test] public void Rollback_ReSimulated_PreDash_Tick_Gets_No_Override() { // DashState is NON-replicated, so prediction rollback does NOT restore it: a re-simulated tick // BEFORE StartTick still sees the post-press window. The override must include the StartTick lower // bound or it stomps dash velocity onto pre-dash ticks (dash-start overshoot under real latency). var (world, group) = MakeWorld("DashRollback", 95); // serverTick 95 < StartTick 100 using (world) { var em = world.EntityManager; var e = MakeDasher(em, new float2(0, 1)); em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 }); em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // input velocity of the pre-dash tick group.Update(); Assert.AreEqual(5f, em.GetComponentData(e).MoveVelocity.x, 1e-3f, "A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock)."); Assert.AreEqual(15f, em.GetComponentData(e).GroundedMovementSharpness, 1e-3f, "A re-simulated PRE-dash tick keeps base sharpness."); } } } }