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(PortalCommand), typeof(MetaCounters), 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
});
// 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 });
// DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick).
static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir)
{
em.SetComponentData(dir, new PortalCommand { HasInteract = 1 });
group.Update();
}
static int RoomEntities(EntityManager em)
{
var q = em.CreateEntityQuery(typeof(RoomTag));
int n = q.CalculateEntityCount();
q.Dispose();
return n;
}
[Test]
public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
var node0 = em.CreateEntity(typeof(RoomTag));
em.SetComponentData(node0, new RoomTag { Room = 0 });
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here)
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
Assert.AreEqual(1, em.GetComponentData(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore");
PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate
var gateInfo = em.GetComponentData(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate");
Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)");
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
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 -> 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");
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 (+1500)");
}
}
[Test]
public void BossClear_Returns_AndBanksExactlyOnce_ClearGated()
{
RunMap map0;
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
using (world)
{
var em = world.EntityManager;
int bossLayer = map0.LayerCount - 1;
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; room persists)
group.Update(); // RoomReward -> RoomExplore
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
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");
var meta = em.GetComponentData(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update();
group.Update();
Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted);
}
}
[Test]
public void Abort_BanksDepthOnly_NoWinCredit()
{
var (world, group, dir, player) = MakeMidRunWorld(2, out var map);
using (world)
{
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);
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");
Assert.AreEqual(0, em.GetComponentData(dir).Pending, "no save spam on abort");
}
}
[Test]
public void RouteGate_PickBeatsSameTickGrace()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
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;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000);
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");
}
}
[Test]
public void RouteGate_GraceAutoPicksLowestOption()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
var gate = em.GetComponentData(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000);
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");
}
}
[Test]
public void RouteGate_Abort_ClosesGateOnTheEdge()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> 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)");
}
}
}
}