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);
}
}
[Test]
public void LaunchGuard_BlocksWhenOutcomeLatched()
{
var (world, group, dir) = MakeWorld();
using (world)
{
var em = world.EntityManager;
MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory });
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle,
"a decided run must not launch (F2 guard)");
}
}
}
}