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 M2 predicted /// projectile integrator). Boots a bare ECS world, registers the system in the /// SimulationSystemGroup, creates a synthetic projectile (LocalTransform + Projectile + enabled /// Simulate), injects a fixed delta-time, ticks N times, and asserts the position advanced by /// exactly Speed * dt * N along the planar (XZ) Direction and that DistanceTravelled accumulated /// the same total. Version-independent and netcode-free, mirroring PlayerMoveSystemTests. /// The system's [UpdateInGroup(PredictedSimulationSystemGroup)] attribute is inert here /// because the system is added to the SimulationSystemGroup manually. /// public class ProjectileMoveSystemTests { [Test] public void Projectile_Advances_By_Speed_Times_Dt_Each_Tick() { using var world = new World("ProjectileMoveTestWorld"); var simulationGroup = world.GetOrCreateSystemManaged(); var moveSystem = world.GetOrCreateSystem(); simulationGroup.AddSystemToUpdateList(moveSystem); simulationGroup.SortSystems(); var em = world.EntityManager; var entity = em.CreateEntity( typeof(LocalTransform), typeof(Projectile), typeof(Simulate)); const float speed = 10f; const float dt = 0.1f; const int ticks = 5; em.SetComponentData(entity, LocalTransform.FromPosition(float3.zero)); em.SetComponentData(entity, new Projectile { Direction = new float2(1f, 0f), SpawnId = 0u, Speed = speed, Damage = 20f, Range = 100f, DistanceTravelled = 0f, }); for (int i = 0; i < ticks; i++) { // Fixed delta so the predicted integration is fully deterministic (no wall-clock). world.SetTime(new TimeData(elapsedTime: dt * (i + 1), deltaTime: dt)); simulationGroup.Update(); } var position = em.GetComponentData(entity).Position; var distance = em.GetComponentData(entity).DistanceTravelled; Assert.AreEqual(speed * dt * ticks, position.x, 1e-3f, "X should advance by Speed * dt each tick for Direction=(1,0)."); Assert.AreEqual(0f, position.y, 1e-3f, "Travel is planar; Y should stay 0."); Assert.AreEqual(0f, position.z, 1e-3f, "Direction=(1,0) maps to +X only; Z should stay 0."); Assert.AreEqual(speed * dt * ticks, distance, 1e-3f, "DistanceTravelled should accumulate Speed * dt each tick."); } [Test] public void Projectile_Integration_Is_Idempotent_Across_Equal_Tick_Batches() { // Determinism/idempotence: the same Direction, Speed and dt must yield the same result // regardless of how the ticks are grouped (mirrors the prediction loop re-simulating a // tick on rollback). Returns (finalPosition, distanceTravelled). (float3 Position, float Distance) RunTicks(int ticks) { using var world = new World("ProjectileMoveDetWorld"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); var em = world.EntityManager; var e = em.CreateEntity( typeof(LocalTransform), typeof(Projectile), typeof(Simulate)); em.SetComponentData(e, LocalTransform.FromPosition(float3.zero)); em.SetComponentData(e, new Projectile { Direction = new float2(0f, 1f), SpawnId = 0u, Speed = 7f, Damage = 20f, Range = 100f, DistanceTravelled = 0f, }); for (int i = 0; i < ticks; i++) { world.SetTime(new TimeData(0.05f * (i + 1), 0.05f)); group.Update(); } var proj = em.GetComponentData(e); return (em.GetComponentData(e).Position, proj.DistanceTravelled); } var a = RunTicks(20); var b = RunTicks(20); Assert.AreEqual(a.Position.z, b.Position.z, 1e-4f, "Two identical runs must produce identical positions."); Assert.AreEqual(a.Distance, b.Distance, 1e-4f, "Two identical runs must produce identical DistanceTravelled."); Assert.AreEqual(7f * 0.05f * 20f, a.Position.z, 1e-3f, "Direction=(0,1) maps to +Z by Speed*dt*N."); Assert.AreEqual(7f * 0.05f * 20f, a.Distance, 1e-3f, "DistanceTravelled equals Speed*dt*N."); } } }