Vault Re-Alignment
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<T>(string name, uint serverTick) where T : unmanaged, ISystem
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||
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<Simulate>(e); // enabled by default
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false); // alive
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("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<CharacterControl>(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<CharacterComponent>(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<DashSystem>("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<CharacterControl>(e).MoveVelocity), 1e-3f,
|
||||
"Recovery tail locks movement to zero (the punishable window).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(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<DashSystem>("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<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"Sharpness restored to default after the dash window elapses.");
|
||||
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(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<DashSystem>("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<PlayerInput>(e).Dash.IsSet,
|
||||
"Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption).");
|
||||
|
||||
group.Update(); // tick 100
|
||||
|
||||
var ds = em.GetComponentData<DashState>(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<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Start_Is_Idempotent_At_The_Same_Tick()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("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<DashState>(e);
|
||||
group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window)
|
||||
var second = em.GetComponentData<DashState>(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<DashSystem>("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<DashState>(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<DashState>(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<PlayerDeathStateSystem>("DashDeath", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponent<PlayerTag>(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<Simulate>(e);
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
|
||||
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(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<DashSystem>("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<CharacterControl>(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<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"A re-simulated PRE-dash tick keeps base sharpness.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user