259 lines
13 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|