using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; using System.Collections.Generic; namespace ProjectM.Tests { /// /// Validation matrix for — the co-op route-pick receiver. Pins: a correctly /// stamped pick latches (the review's non-maskable acceptance criterion — a validation bug here silently /// degrades to the grace auto-pick and a linear game); the first-commit latch under two same-tick picks; the /// re-meaned run-identity stale-reject ((uint)ForRunEpoch == RunSeed); the layer stale-reject; index bounds; /// the N3 base-region sender reject; the closed-gate reject; and that requests are ALWAYS consumed. /// public class RouteSelectSystemTests { const uint Seed = 999u; const int GateLayer = 2; readonly List _worlds = new(); [TearDown] public void Cleanup() { foreach (var w in _worlds) if (w.IsCreated) w.Dispose(); _worlds.Clear(); } static (World world, SimulationSystemGroup group, Entity dir) MakeGateWorld(byte lifecycle = RunLifecycle.RouteSelect) { var world = new World("RouteSelectTest"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = GateLayer, RunSeed = Seed, RouteOptionCount = 2, RouteOpt0Col = 0, RouteOpt1Col = 2, }); em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 3 }); return (world, group, dir); } static Entity MakePlayer(EntityManager em, int networkId, byte region) { var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag)); em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); em.SetComponentData(e, new RegionTag { Region = region }); return e; } static void SendPick(EntityManager em, int networkId, byte optionIndex, int forSeed, int forLayer) { var conn = em.CreateEntity(typeof(NetworkId)); em.SetComponentData(conn, new NetworkId { Value = networkId }); var req = em.CreateEntity(typeof(RouteSelectRequest), typeof(ReceiveRpcCommandRequest)); em.SetComponentData(req, new RouteSelectRequest { OptionIndex = optionIndex, ForRunEpoch = forSeed, ForLayer = forLayer }); em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); } static int PendingRequests(EntityManager em) { using var q = em.CreateEntityQuery(typeof(RouteSelectRequest)); return q.CalculateEntityCount(); } [Test] public void ValidPick_Latches_WithTrueServerEpoch() { var (world, group, dir) = MakeGateWorld(); using (world) { var em = world.EntityManager; MakePlayer(em, 1, RegionId.Expedition); SendPick(em, 1, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); group.Update(); var cmd = em.GetComponentData(dir); Assert.AreEqual(1, cmd.HasPick, "a correctly-stamped pick MUST latch (non-maskable criterion)"); Assert.AreEqual(1, cmd.OptionIndex, "the picked option"); Assert.AreEqual(3, cmd.ForRunEpoch, "stamped from the TRUE server epoch, never the client echo"); Assert.AreEqual(0, PendingRequests(em), "request consumed"); } } [Test] public void TwoSameTickPicks_FirstWins() { var (world, group, dir) = MakeGateWorld(); using (world) { var em = world.EntityManager; MakePlayer(em, 1, RegionId.Expedition); MakePlayer(em, 2, RegionId.Expedition); SendPick(em, 1, optionIndex: 0, forSeed: (int)Seed, forLayer: GateLayer); // created first -> wins SendPick(em, 2, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); group.Update(); var cmd = em.GetComponentData(dir); Assert.AreEqual(1, cmd.HasPick, "exactly one commit"); Assert.AreEqual(0, cmd.OptionIndex, "the FIRST accepted pick wins (in-place latch, DR-014)"); Assert.AreEqual(0, PendingRequests(em), "both requests consumed"); } } [Test] public void Rejects_WrongSeed_WrongLayer_OutOfRange_BaseSender_ClosedGate() { // Wrong run-identity token (a stale pick from the previous run). var (w1, g1, d1) = MakeGateWorld(); _worlds.Add(w1); MakePlayer(w1.EntityManager, 1, RegionId.Expedition); SendPick(w1.EntityManager, 1, 0, forSeed: (int)Seed + 1, forLayer: GateLayer); g1.Update(); Assert.AreEqual(0, w1.EntityManager.GetComponentData(d1).HasPick, "wrong seed rejected"); Assert.AreEqual(0, PendingRequests(w1.EntityManager)); // Wrong layer (a pick from the previous gate of the SAME run). var (w2, g2, d2) = MakeGateWorld(); _worlds.Add(w2); MakePlayer(w2.EntityManager, 1, RegionId.Expedition); SendPick(w2.EntityManager, 1, 0, (int)Seed, forLayer: GateLayer - 1); g2.Update(); Assert.AreEqual(0, w2.EntityManager.GetComponentData(d2).HasPick, "stale layer rejected"); // Option index out of the published range. var (w3, g3, d3) = MakeGateWorld(); _worlds.Add(w3); MakePlayer(w3.EntityManager, 1, RegionId.Expedition); SendPick(w3.EntityManager, 1, optionIndex: 2, (int)Seed, GateLayer); // count is 2 -> max index 1 g3.Update(); Assert.AreEqual(0, w3.EntityManager.GetComponentData(d3).HasPick, "out-of-range rejected"); // Base-region sender (N3): a home-bound joiner cannot commit the party's route. var (w4, g4, d4) = MakeGateWorld(); _worlds.Add(w4); MakePlayer(w4.EntityManager, 1, RegionId.Base); SendPick(w4.EntityManager, 1, 0, (int)Seed, GateLayer); g4.Update(); Assert.AreEqual(0, w4.EntityManager.GetComponentData(d4).HasPick, "base sender rejected (N3)"); // Gate closed (mid-room): the pick is dropped, never queued. var (w5, g5, d5) = MakeGateWorld(lifecycle: RunLifecycle.InRoom); _worlds.Add(w5); MakePlayer(w5.EntityManager, 1, RegionId.Expedition); SendPick(w5.EntityManager, 1, 0, (int)Seed, GateLayer); g5.Update(); Assert.AreEqual(0, w5.EntityManager.GetComponentData(d5).HasPick, "closed gate rejected"); Assert.AreEqual(0, PendingRequests(w5.EntityManager), "request still consumed"); } [Test] public void AlreadyLatched_LaterPickIgnored() { var (world, group, dir) = MakeGateWorld(); using (world) { var em = world.EntityManager; MakePlayer(em, 1, RegionId.Expedition); em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 3, ForLayer = GateLayer }); SendPick(em, 1, optionIndex: 1, (int)Seed, GateLayer); group.Update(); var cmd = em.GetComponentData(dir); Assert.AreEqual(0, cmd.OptionIndex, "an already-latched gate ignores later picks"); } } } }