e27a495530
- 10 EditMode files: every bare trailing world.Dispose() now inside using(world){} (single-world) or a [TearDown]+List<World> (the multi-world reject-matrix tests in BoonApplyTests/RouteSelectSystemTests) — an assertion failure can no longer leak the World and mask the true first failure. No test logic changed.
- HudSystem: collapse the blank-line run before the class close; trim the extracted route-map clause from the READY-panel comment.
466/466 EditMode tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
8.1 KiB
C#
183 lines
8.1 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Validation matrix for <see cref="RouteSelectSystem"/> — 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.
|
|
/// </summary>
|
|
public class RouteSelectSystemTests
|
|
{
|
|
const uint Seed = 999u;
|
|
const int GateLayer = 2;
|
|
readonly List<World> _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<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<RouteSelectSystem>());
|
|
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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(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<RouteCommand>(dir);
|
|
Assert.AreEqual(0, cmd.OptionIndex, "an already-latched gate ignores later picks");
|
|
}
|
|
}
|
|
}
|
|
}
|