Files
Project-M/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs
T
2026-07-04 16:57:40 -07:00

259 lines
13 KiB
C#

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
{
/// <summary>
/// Plain-Entities EditMode tests for <see cref="RunDirectorSystem"/>'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).
/// </summary>
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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet)
var gateInfo = em.GetComponentData<RunInfo>(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<RunInfo>(dir);
var run = em.GetComponentData<RunRuntime>(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<RouteCommand>(dir).HasPick, "latch consumed");
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(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<RunInfo>(dir);
info0.CurrentRoom = bossLayer;
em.SetComponentData(dir, info0);
var run0 = em.GetComponentData<RunRuntime>(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<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "win meter +1 on a boss clear");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
var threat = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)");
Assert.AreEqual(1, threat.ExpeditionsCompleted);
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(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<GoalProgress>(dir).Charge, "no double credit (F7)");
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(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<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(dir).Charge, "no win credit on an abort (D-F3)");
var meta = em.GetComponentData<MetaCounters>(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<ThreatState>(dir);
Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort");
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(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<RunInfo>(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<RunInfo>(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<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000); // grace elapses, nobody picked
group.Update();
var info = em.GetComponentData<RunInfo>(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<RunInfo>(dir).RouteOptionCount, 0);
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
group.Update(); // RouteSelect -> Returning (abort)
var info = em.GetComponentData<RunInfo>(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();
}
}
}