using NUnit.Framework; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities determinism test for (the M1 predicted move /// system). Boots a bare ECS world, registers the system in the SimulationSystemGroup, creates a /// synthetic player (PlayerInput + EffectiveCharacterStats + LocalTransform + enabled Simulate), /// injects a fixed delta-time, ticks N times, and asserts the position advanced by exactly /// MoveSpeed * dt * N. As of M3 move speed is read from EffectiveCharacterStats (the data-driven /// effective stat) rather than the removed PlayerMoveStats. Version-independent and netcode-free. /// public class PlayerMoveSystemTests { [Test] public void PlayerMove_Advances_By_MoveSpeed_Times_Dt_Each_Tick() { using var world = new World("PlayerMoveTestWorld"); var simulationGroup = world.GetOrCreateSystemManaged(); var moveSystem = world.GetOrCreateSystem(); simulationGroup.AddSystemToUpdateList(moveSystem); simulationGroup.SortSystems(); var em = world.EntityManager; var entity = em.CreateEntity( typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate)); const float moveSpeed = 5f; const float dt = 0.1f; const int ticks = 10; em.SetComponentData(entity, LocalTransform.FromPosition(float3.zero)); em.SetComponentData(entity, new EffectiveCharacterStats { MoveSpeed = moveSpeed, TurnRateRadiansPerSec = 0f, MaxHealth = 0f }); em.SetComponentData(entity, new PlayerInput { Move = new float2(1f, 0f), Aim = float2.zero }); for (int i = 0; i < ticks; i++) { // Fixed delta so the predicted move is fully deterministic (no wall-clock). world.SetTime(new TimeData(elapsedTime: dt * (i + 1), deltaTime: dt)); simulationGroup.Update(); } var position = em.GetComponentData(entity).Position; Assert.AreEqual(moveSpeed * dt * ticks, position.x, 1e-3f, "X should advance by MoveSpeed * dt each tick for Move=(1,0)."); Assert.AreEqual(0f, position.y, 1e-3f, "Movement is planar; Y should stay 0."); Assert.AreEqual(0f, position.z, 1e-3f, "Move=(1,0) maps to +X only; Z should stay 0."); } [Test] public void PlayerMove_Is_Idempotent_Across_Equal_Tick_Batches() { // Determinism/idempotence: the same inputs and dt must yield the same result regardless // of how the ticks are grouped (mirrors the prediction loop re-simulating a tick). float3 RunTicks(int ticks) { using var world = new World("PlayerMoveDetWorld"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); var em = world.EntityManager; var e = em.CreateEntity( typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate)); em.SetComponentData(e, LocalTransform.FromPosition(float3.zero)); em.SetComponentData(e, new EffectiveCharacterStats { MoveSpeed = 3f, TurnRateRadiansPerSec = 0f, MaxHealth = 0f }); em.SetComponentData(e, new PlayerInput { Move = new float2(0f, 1f), Aim = float2.zero }); for (int i = 0; i < ticks; i++) { world.SetTime(new TimeData(0.05f * (i + 1), 0.05f)); group.Update(); } return em.GetComponentData(e).Position; } var a = RunTicks(20); var b = RunTicks(20); Assert.AreEqual(a.z, b.z, 1e-4f, "Two identical runs must produce identical positions."); Assert.AreEqual(3f * 0.05f * 20f, a.z, 1e-3f, "Move=(0,1) maps to +Z by MoveSpeed*dt*N."); } } }