Vault Re-Alignment

This commit is contained in:
2026-06-09 23:26:20 -07:00
parent a7405c3f38
commit da522efe7a
63 changed files with 119048 additions and 15 deletions
@@ -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.");
}
}
}
}