using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the ready-check spine: (RPC → PlayerReady, /// Staging/Launching-only) ordered before (the all-ready rising-edge launch, the /// un-ready countdown abort, the F2 outcome guard, the sub-slot teleport out/home, and the Returning-edge /// ready-flag clear). Ticks are driven manually through NetworkTime, so the countdown/dwell paths that Play-mode /// polling races past are pinned deterministically here. /// public class ReadyCheckSystemTests { const uint T0 = 1000; static (World world, SimulationSystemGroup group, Entity dir) MakeWorld() { var world = new World("ReadyCheckTest"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; var nt = em.CreateEntity(typeof(NetworkTime)); em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Staging }); em.SetComponentData(dir, new RunRuntime { HostSalt = 1u }); return (world, group, dir); } static void SetTick(World world, uint tick) { var em = world.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); } static Entity MakePlayer(EntityManager em, int networkId) { var e = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform)); em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); em.SetComponentData(e, new RegionTag { Region = RegionId.Base }); em.SetComponentData(e, LocalTransform.Identity); return e; } static Entity MakeConnection(EntityManager em, int networkId) { var e = em.CreateEntity(typeof(NetworkId)); em.SetComponentData(e, new NetworkId { Value = networkId }); return e; } static void SendToggle(EntityManager em, Entity conn, byte ready) { var e = em.CreateEntity(typeof(ReadyToggleRequest), typeof(ReceiveRpcCommandRequest)); em.SetComponentData(e, new ReadyToggleRequest { Ready = ready }); em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); } static int PendingRequests(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ReadyToggleRequest)); return q.CalculateEntityCount(); } [Test] public void Toggle_SetsReady_AndSoloLaunches_SameTick() { var (world, group, dir) = MakeWorld(); using (world) { var em = world.EntityManager; var player = MakePlayer(em, 1); var conn = MakeConnection(em, 1); SendToggle(em, conn, 1); group.Update(); Assert.AreEqual(1, em.GetComponentData(player).Value, "toggle landed"); Assert.AreEqual(0, PendingRequests(em), "request consumed"); var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.Launching, info.Lifecycle, "1/1 ready -> rising edge -> Launching"); Assert.AreNotEqual(0u, info.LaunchTick, "countdown telegraph armed"); Assert.AreNotEqual(0u, info.RunSeed, "run seeded"); Assert.GreaterOrEqual(info.RoomCount, 6, "seed-varied length floor"); Assert.LessOrEqual(info.RoomCount, 10, "seed-varied length cap"); } } [Test] public void Toggle_Ignored_MidRun() { var (world, group, dir) = MakeWorld(); using (world) { var em = world.EntityManager; var player = MakePlayer(em, 1); var conn = MakeConnection(em, 1); var info = em.GetComponentData(dir); info.Lifecycle = RunLifecycle.InRoom; em.SetComponentData(dir, info); SendToggle(em, conn, 1); group.Update(); Assert.AreEqual(0, em.GetComponentData(player).Value, "mid-run toggle dropped"); Assert.AreEqual(0, PendingRequests(em), "request still consumed"); } } [Test] public void PartialReady_DoesNotLaunch() { var (world, group, dir) = MakeWorld(); using (world) { var em = world.EntityManager; MakePlayer(em, 1); MakePlayer(em, 2); var conn1 = MakeConnection(em, 1); SendToggle(em, conn1, 1); group.Update(); Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle, "1/2 ready must NOT launch"); } } [Test] public void UnReady_DuringCountdown_Aborts() { var (world, group, dir) = MakeWorld(); using (world) { var em = world.EntityManager; MakePlayer(em, 1); var conn = MakeConnection(em, 1); SendToggle(em, conn, 1); group.Update(); Assert.AreEqual(RunLifecycle.Launching, em.GetComponentData(dir).Lifecycle); SendToggle(em, conn, 0); // change of heart during the 3-2-1 group.Update(); var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle, "un-ready aborts the countdown"); Assert.AreEqual(0u, info.LaunchTick, "telegraph cleared"); } } [Test] public void Launch_TeleportsPartyOut_ThenHome_AndClearsReady() { var (world, group, dir) = MakeWorld(); using (world) { var em = world.EntityManager; var player = MakePlayer(em, 1); var conn = MakeConnection(em, 1); SendToggle(em, conn, 1); group.Update(); // Staging -> Launching (countdown armed at T0) SetTick(world, T0 + 200); // past the 180-tick countdown group.Update(); // enter room 0 (the real traversal, Step 7) Assert.AreEqual(RegionId.Expedition, em.GetComponentData(player).Region, "party region flipped to Expedition"); Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 999f, "party teleported to the expedition room origin (sub-slot 0 at +1000)"); Assert.AreEqual(1f, em.GetComponentData(player).Scale, 1e-4f, "Scale preserved through the teleport (never FromPosition)"); Assert.AreEqual(RunLifecycle.InRoom, em.GetComponentData(dir).Lifecycle, "room 0 active"); // Simulate the all-left abort (disconnect edge): drop the player's region externally. em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); SetTick(world, T0 + 260); group.Update(); // InRoom -> Returning (abort, no credit) SetTick(world, T0 + 320); group.Update(); // Returning: teleport home + clear ready flags -> StagingStaging Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "back home"); Assert.Less(em.GetComponentData(player).Position.x, 100f, "position restored to base"); Assert.AreEqual(0, em.GetComponentData(player).Value, "ready flag cleared on return"); var run = em.GetComponentData(dir); Assert.AreEqual(run.RunEpoch, run.LastBankedRunEpoch, "terminal bank latch fired once"); Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); } } } }