using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for 's Step-7 linear traversal: the objective /// Cleared edge tears the room down AT RoomReward ENTRY and the next room spawns only on the advance (the /// teardown-before-spawn empty-tick invariant), the ping-pong sub-slot flip + RoomEpoch bump + teleport, the boss /// terminal, and the CLEAR-GATED once-per-RunEpoch bank (boss-clear credits Charge/RunsCompleted/retaliation + /// save; an abort banks ONLY the honest depth high-water — D-F3/F7/C7). /// public class RunDirectorTraversalTests { const uint Seed = 777u; const uint T0 = 2000; static (World world, SimulationSystemGroup group, Entity dir, Entity player) MakeMidRunWorld( int currentRoom, out RunMap map) { var world = new World("TraversalTest"); var group = world.GetOrCreateSystemManaged(); 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) }); map = RunMapMath.Generate(Seed); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom, CurrentRoom = currentRoom, RoomCount = map.LayerCount, RunSeed = Seed, }); em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 1, RoomEpoch = currentRoom + 1, CurrentNodeId = RunMap.NodeId(currentRoom, 0), ActiveSubSlot = (byte)(currentRoom & 1), RoomsClearedThisRun = currentRoom, // rooms before this one were cleared }); em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 }); // Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it. var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform), typeof(RunParticipant)); em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); em.SetComponentData(player, LocalTransform.Identity); return (world, group, dir, player); } static void SetTick(World world, uint tick) { var em = world.EntityManager; var q = em.CreateEntityQuery(typeof(NetworkTime)); em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); q.Dispose(); } static void MarkCleared(EntityManager em, Entity dir) => em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 }); static int RoomEntities(EntityManager em) { var q = em.CreateEntityQuery(typeof(RoomTag)); int n = q.CalculateEntityCount(); q.Dispose(); return n; } [Test] public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump() { var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; // Room-0 content that must die at the RoomReward entry. var node0 = em.CreateEntity(typeof(RoomTag)); em.SetComponentData(node0, new RoomTag { Room = 0 }); MarkCleared(em, dir); group.Update(); // InRoom -> RoomReward + teardown Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData(dir).Lifecycle); Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)"); Assert.AreEqual(1, em.GetComponentData(dir).RoomsClearedThisRun, "honest depth counter"); group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet) var gateInfo = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)"); Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published"); // Commit the party's pick directly through the server-only latch (the RPC path is pinned in // RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1. byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1); byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col : pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col; em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column var info = em.GetComponentData(dir); var run = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); Assert.AreEqual(1, info.CurrentRoom); Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)"); Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped"); Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed"); Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published"); Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType); Assert.AreEqual(0, em.GetComponentData(dir).HasPick, "latch consumed"); Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance"); Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 1499f, "party teleported onto the idle sub-slot (+1500)"); world.Dispose(); } [Test] public void BossClear_Returns_AndBanksExactlyOnce_ClearGated() { RunMap map0; var (world, group, dir, player) = MakeMidRunWorld(0, out map0); var em = world.EntityManager; int bossLayer = map0.LayerCount - 1; // Jump the state to the boss room. var info0 = em.GetComponentData(dir); info0.CurrentRoom = bossLayer; em.SetComponentData(dir, info0); var run0 = em.GetComponentData(dir); run0.CurrentNodeId = map0.BossNodeId; run0.ActiveSubSlot = (byte)(bossLayer & 1); run0.RoomsClearedThisRun = bossLayer; em.SetComponentData(dir, run0); MarkCleared(em, dir); group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1) group.Update(); // RoomReward -> Returning group.Update(); // Returning: bank + teleport home -> Staging var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle); Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "party home"); Assert.AreEqual(1, em.GetComponentData(dir).Charge, "win meter +1 on a boss clear"); var meta = em.GetComponentData(dir); Assert.AreEqual(1, meta.RunsCompleted, "run completed"); Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared"); var threat = em.GetComponentData(dir); Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)"); Assert.AreEqual(1, threat.ExpeditionsCompleted); Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested"); Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated"); group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch) group.Update(); Assert.AreEqual(1, em.GetComponentData(dir).Charge, "no double credit (F7)"); Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted); world.Dispose(); } [Test] public void Abort_BanksDepthOnly_NoWinCredit() { var (world, group, dir, player) = MakeMidRunWorld(2, out var map); var em = world.EntityManager; // All expedition players gone mid-room-2 (rooms 0-1 cleared) -> abort. em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); group.Update(); // InRoom -> Returning (abort) group.Update(); // Returning: depth-only bank -> Staging Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); Assert.AreEqual(0, em.GetComponentData(dir).Charge, "no win credit on an abort (D-F3)"); var meta = em.GetComponentData(dir); Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit"); Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan"); var threat = em.GetComponentData(dir); Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort"); Assert.AreEqual(0, em.GetComponentData(dir).Pending, "no save spam on abort"); world.Dispose(); } [Test] public void RouteGate_PickBeatsSameTickGrace() { var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); group.Update(); // -> RoomReward (teardown) group.Update(); // -> RouteSelect (gate open, grace armed at T0) var gate = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle); byte pickIdx = (byte)(gate.RouteOptionCount - 1); byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col; // A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence). em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); SetTick(world, T0 + 100000); // way past any grace group.Update(); var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); Assert.AreEqual(pickedCol, info.CurrentCol, "the accepted pick beats the same-tick grace expiry"); world.Dispose(); } [Test] public void RouteGate_GraceAutoPicksLowestOption() { var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); group.Update(); // -> RoomReward group.Update(); // -> RouteSelect var gate = em.GetComponentData(dir); byte lowestCol = gate.RouteOpt0Col; SetTick(world, T0 + 100000); // grace elapses, nobody picked group.Update(); var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the AFK backstop advances the run"); Assert.AreEqual(lowestCol, info.CurrentCol, "deterministic lowest-index reachable auto-pick"); world.Dispose(); } [Test] public void RouteGate_Abort_ClosesGateOnTheEdge() { var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); group.Update(); // -> RoomReward group.Update(); // -> RouteSelect Assert.Greater((int)em.GetComponentData(dir).RouteOptionCount, 0); em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left group.Update(); // RouteSelect -> Returning (abort) var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle); Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)"); world.Dispose(); } } }