using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Regression pins for the two post-impl-review fixes on : /// (1) a dead-respawned (base-region) player's stale neither holds the /// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal /// pickable through the next fight); (2) room advances teleport ONLY s — /// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner /// stays at base (spec §2.2 closed party). /// public class RunRosterRegressionTests { const uint Seed = 777u; const uint T0 = 2000; static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map) { var world = new World("RosterTest"); 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(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); return (world, group, dir); } static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending) { var player = participant ? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant)) : em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform), typeof(BoonOffer)); em.SetComponentData(player, new RegionTag { Region = region }); em.SetComponentData(player, LocalTransform.Identity); em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 }); return player; } [Test] public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit() { var (world, group, dir) = MakeWorld(out var map); var em = world.EntityManager; em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.RoomReward, CurrentRoom = 1, CurrentCol = 0, RoomCount = map.LayerCount, RunSeed = Seed, }); em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 1, RoomEpoch = 2, // Grace far in the future: ONLY the all-picked path can advance this tick. RewardGraceTick = TickUtil.NonZero(T0 + 100000), }); var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge group.Update(); var info = em.GetComponentData(dir); Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle, "a base-region player's stale Pending must not hold the reward gate"); Assert.AreEqual(0, em.GetComponentData(deadAtBase).Pending, "the stale offer is stripped on the gate exit"); Assert.AreEqual(0, em.GetComponentData(alive).Pending, "no offer survives the gate"); world.Dispose(); } [Test] public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase() { var (world, group, dir) = MakeWorld(out var map); var em = world.EntityManager; em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.RouteSelect, CurrentRoom = 0, CurrentCol = 0, RoomCount = map.LayerCount, RunSeed = Seed, RouteOptionCount = 1, RouteOpt0Col = 0, }); em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 1, RoomEpoch = 1, RouteGraceTick = TickUtil.NonZero(T0 + 100000), }); em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 }); var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home group.Update(); var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run"); Assert.GreaterOrEqual(em.GetComponentData(inRoom).Position.x, 1000f, "the in-room participant rides the advance"); Assert.GreaterOrEqual(em.GetComponentData(deadAtBase).Position.x, 1000f, "a dead-respawned participant is re-conscripted onto the new room"); Assert.AreEqual(0f, em.GetComponentData(lateJoiner).Position.x, "a non-participant late joiner is never yanked into the fight"); Assert.AreEqual(RegionId.Base, em.GetComponentData(lateJoiner).Region, "the late joiner stays in the Base relevancy bucket"); world.Dispose(); } } }