Files
Project-M/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs
T
kronic b34945c2d2 LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:27:12 -07:00

272 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(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<RunInfo>(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData<RunInfo>(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<RunInfo>(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<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");
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 (+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<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; 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<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update();
group.Update();
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(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<RunInfo>(dir).Lifecycle);
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");
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(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<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;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000);
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");
}
}
[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<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000);
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");
}
}
[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<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)");
}
}
}
}