diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs
new file mode 100644
index 000000000..33a667f85
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs
@@ -0,0 +1,182 @@
+using NUnit.Framework;
+using ProjectM.Server;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.NetCode;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Plain-Entities EditMode coverage for the server-only PrepPurchaseSystem (DR-046 base prep-loadout spend).
+ /// Exercises the afford / soft-fail / once-per-run / non-Staging-reject paths and the DR-014 same-tick atomicity
+ /// (a second barely-affordable buy in the same tick can't also pass). Modeled on MetaSpendSystemTests.
+ ///
+ public class PrepPurchaseSystemTests
+ {
+ // Director carries the RunInfo singleton + the shared ResourceLedger (StorageEntry buffer), seeded with one resource.
+ static Entity MakeDirector(EntityManager em, byte lifecycle, byte resId, int amount)
+ {
+ var e = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(StorageEntry));
+ em.SetComponentData(e, new RunInfo { Lifecycle = lifecycle });
+ if (amount > 0)
+ {
+ var ledger = em.GetBuffer(e);
+ StorageMath.Deposit(ledger, resId, amount);
+ }
+ return e;
+ }
+
+ // A player (PlayerTag + GhostOwner + StatModifier buffer) plus its connection entity (NetworkId).
+ static (Entity player, Entity conn) MakePlayer(EntityManager em, int netId)
+ {
+ var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(StatModifier));
+ em.SetComponentData(player, new GhostOwner { NetworkId = netId });
+ var conn = em.CreateEntity(typeof(NetworkId));
+ em.SetComponentData(conn, new NetworkId { Value = netId });
+ return (player, conn);
+ }
+
+ static void SendRequest(EntityManager em, Entity conn, byte optionId)
+ {
+ var e = em.CreateEntity(typeof(PrepPurchaseRequest), typeof(ReceiveRpcCommandRequest));
+ em.SetComponentData(e, new PrepPurchaseRequest { OptionId = optionId });
+ em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
+ }
+
+ // Count StatModifier rows in the prep SourceId band on a player.
+ static int PrepRowCount(EntityManager em, Entity player)
+ {
+ var mods = em.GetBuffer(player);
+ int n = 0;
+ for (int i = 0; i < mods.Length; i++)
+ if (mods[i].SourceId >= Tuning.PrepSourceIdBase && mods[i].SourceId < Tuning.PrepSourceIdBase + 256u)
+ n++;
+ return n;
+ }
+
+ static int OpenRequests(EntityManager em)
+ {
+ using var q = em.CreateEntityQuery(typeof(PrepPurchaseRequest));
+ return q.CalculateEntityCount();
+ }
+
+ [Test]
+ public void Purchase_Appends_Prep_Modifier_Withdraws_And_Consumes_Request()
+ {
+ var (world, group) = TestWorld.Make("Prep_Buy", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 30);
+ var (player, conn) = MakePlayer(em, 1);
+ SendRequest(em, conn, 0); // id0: Ore 30 -> MaxHealth +30 Flat
+
+ group.Update();
+
+ Assert.AreEqual(1, PrepRowCount(em, player), "One prep modifier appended.");
+ var mods = em.GetBuffer(player);
+ Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
+ Assert.AreEqual(30f, mods[0].Value, 1e-4f);
+ Assert.AreEqual(Tuning.PrepSourceIdBase + 0u, mods[0].SourceId);
+ Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Ore fully withdrawn.");
+ Assert.AreEqual(0, OpenRequests(em), "Request consumed.");
+ }
+ }
+
+ [Test]
+ public void Reject_When_Broke_Leaves_Ledger_Untouched_But_Consumes_Request()
+ {
+ var (world, group) = TestWorld.Make("Prep_Broke", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 20); // < 30 cost
+ var (player, conn) = MakePlayer(em, 1);
+ SendRequest(em, conn, 0);
+
+ group.Update();
+
+ Assert.AreEqual(0, PrepRowCount(em, player), "No modifier when broke.");
+ Assert.AreEqual(20, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Pre-check skips Withdraw; ledger untouched.");
+ Assert.AreEqual(0, OpenRequests(em), "Request still consumed on reject.");
+ }
+ }
+
+ [Test]
+ public void Already_Owned_Does_Not_Rebuy()
+ {
+ var (world, group) = TestWorld.Make("Prep_Owned", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
+ var (player, conn) = MakePlayer(em, 1);
+ em.GetBuffer(player).Add(new StatModifier { SourceId = Tuning.PrepSourceIdBase + 0u, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f });
+ SendRequest(em, conn, 0);
+
+ group.Update();
+
+ Assert.AreEqual(1, PrepRowCount(em, player), "Still exactly one row (once per run == SourceId presence).");
+ Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "No second withdraw.");
+ }
+ }
+
+ [Test]
+ public void Non_Staging_Is_Rejected()
+ {
+ var (world, group) = TestWorld.Make("Prep_NonStaging", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.InRoom, ResourceId.Ore, 60);
+ var (player, conn) = MakePlayer(em, 1);
+ SendRequest(em, conn, 0);
+
+ group.Update();
+
+ Assert.AreEqual(0, PrepRowCount(em, player), "No purchase outside Staging.");
+ Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Ledger untouched outside Staging.");
+ Assert.AreEqual(0, OpenRequests(em), "Request consumed even when rejected.");
+ }
+ }
+
+ [Test]
+ public void Unknown_Option_Id_Is_Dropped()
+ {
+ var (world, group) = TestWorld.Make("Prep_Unknown", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
+ var (player, conn) = MakePlayer(em, 1);
+ SendRequest(em, conn, 99); // no such row
+
+ group.Update();
+
+ Assert.AreEqual(0, PrepRowCount(em, player));
+ Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore));
+ Assert.AreEqual(0, OpenRequests(em), "Unknown-id request still consumed.");
+ }
+ }
+
+ [Test]
+ public void Two_Same_Tick_Barely_Enough_Exactly_One_Succeeds()
+ {
+ var (world, group) = TestWorld.Make("Prep_Atomic", tick: 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Aether, 25); // enough for exactly ONE 25-Aether buy
+ var (player, conn) = MakePlayer(em, 1);
+ SendRequest(em, conn, 2); // Aether 25 -> MeleeDamage
+ SendRequest(em, conn, 3); // Aether 25 -> Damage
+
+ group.Update();
+
+ Assert.AreEqual(1, PrepRowCount(em, player), "Exactly one of two same-tick 25-Aether buys succeeds (DR-014 in-loop pre-check).");
+ Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Aether), "Aether withdrawn once; never negative.");
+ Assert.AreEqual(0, OpenRequests(em), "Both requests consumed.");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta
new file mode 100644
index 000000000..34d922c0c
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 02cf1a6cbcc3b804191e5e0169c1402d
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs
new file mode 100644
index 000000000..5bad504bf
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs
@@ -0,0 +1,42 @@
+using NUnit.Framework;
+using ProjectM.Server;
+using ProjectM.Simulation;
+using Unity.Entities;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Guards the ComponentSystemSorter "circular dependency" hazard that is INVISIBLE to the rest of the suite:
+ /// every other fixture registers a single system then sorts, so a cross-system [UpdateBefore/After] cycle can
+ /// never surface — it only throws at world creation in Play. This registers the REAL ordered server-sim set
+ /// (every system that participates in an UpdateBefore/After relation, or its constraint is silently ignored)
+ /// into one SimulationSystemGroup and sorts, reproducing the Play-time sort headlessly. Only SortSystems runs
+ /// (never Update), so a bare world with no entities/singletons suffices.
+ ///
+ public class SystemOrderingCycleTests
+ {
+ [Test]
+ public void RunCycleCombatChain_Sorts_Without_A_Dependency_Cycle()
+ {
+ using var world = new World("OrderCycleGuard");
+ var group = world.GetOrCreateSystemManaged();
+
+ void Add() where T : unmanaged, ISystem
+ => group.AddSystemToUpdateList(world.GetOrCreateSystem());
+
+ // RPC-receive systems ordered before the run director
+ Add(); Add(); Add();
+ Add(); Add(); Add(); Add();
+ // Run director + the systems ordered around it and the cycle phase
+ Add(); Add(); Add();
+ Add(); Add(); Add();
+ Add(); Add();
+ // Combat sub-chain in the same group
+ Add(); Add(); Add();
+ Add(); Add(); Add();
+
+ Assert.DoesNotThrow(() => group.SortSystems(),
+ "A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta
new file mode 100644
index 000000000..d6cd6ea17
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e160f942a69b8344fbe476a973a3e8be
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs
new file mode 100644
index 000000000..400cd24b2
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Anti-regression guard for the "an edit swallowed the [Test] attribute" hazard — a real dead test shipped at
+ /// EnemyAIMathTests.cs:107 (a regression guard for the enemy-stuck-on-cover fix that silently never ran). Scans
+ /// every *Tests.cs source file in this directory and FAILS if a parameterless public-void method (the suite's
+ /// test-method shape — every helper is static, so `public void` uniquely selects tests) is not immediately
+ /// preceded by a runner attribute. File I/O is available at EditMode-test time.
+ ///
+ public class TestAttributeGuardTests
+ {
+ static string ThisDir([CallerFilePath] string p = "") => Path.GetDirectoryName(p);
+
+ static readonly Regex TestMethod = new Regex(@"^\s*public\s+void\s+[A-Za-z_]\w*\s*\(\s*\)", RegexOptions.Compiled);
+ static readonly Regex RunnerAttr = new Regex(@"\[\s*(Test|TestCase|TestCaseSource|Theory|SetUp|TearDown|OneTimeSetUp|OneTimeTearDown)\b", RegexOptions.Compiled);
+
+ [Test]
+ public void Every_Public_Void_Test_Method_Has_A_Runner_Attribute()
+ {
+ var dir = ThisDir();
+ Assert.IsTrue(Directory.Exists(dir), $"Test source dir not found: {dir}");
+
+ var offenders = new List();
+ foreach (var file in Directory.GetFiles(dir, "*Tests.cs"))
+ {
+ var lines = File.ReadAllLines(file);
+ for (int i = 0; i < lines.Length; i++)
+ {
+ if (!TestMethod.IsMatch(lines[i]))
+ continue;
+ // Walk upward past blank / comment lines to the nearest attribute or non-trivial line.
+ bool attributed = false;
+ for (int j = i - 1; j >= 0; j--)
+ {
+ string t = lines[j].Trim();
+ if (t.Length == 0 || t.StartsWith("//") || t.StartsWith("/*") || t.StartsWith("*"))
+ continue;
+ if (t.StartsWith("["))
+ {
+ if (RunnerAttr.IsMatch(t)) { attributed = true; break; }
+ continue; // a non-runner attribute line — keep scanning the attribute block
+ }
+ break; // hit a brace / statement: no attribute block above this method
+ }
+ if (!attributed)
+ offenders.Add($"{Path.GetFileName(file)}:{i + 1} {lines[i].Trim()}");
+ }
+ }
+
+ Assert.IsEmpty(offenders,
+ "Test methods missing a runner attribute (swallowed [Test]?):\n" + string.Join("\n", offenders));
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta
new file mode 100644
index 000000000..314c99817
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b4a83aae5c80ca347bbc6b9583df8b23
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/TestWorld.cs b/Assets/_Project/Tests/EditMode/TestWorld.cs
new file mode 100644
index 000000000..adf665aa5
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TestWorld.cs
@@ -0,0 +1,75 @@
+using ProjectM.Simulation;
+using Unity.Core;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Plain-Entities EditMode harness. Behaviourally identical to the hand-rolled MakeWorld/SetServerTick idioms
+ /// scattered across the suite (a NetworkTime-seeded SimulationSystemGroup world), consolidated so the boilerplate
+ /// has one home. Named NOT to end in "Tests" so the attribute-guard meta-test skips it.
+ ///
+ static class TestWorld
+ {
+ /// World + SimulationSystemGroup, NetworkTime seeded to , TimeData = 1/60.
+ /// sets WorldFlags.GameServer (the IsServer() gate some systems read).
+ public static (World world, SimulationSystemGroup group) Make(string name = "TestWorld", uint tick = 1, bool server = false)
+ {
+ var world = server ? new World(name, WorldFlags.Game | WorldFlags.GameServer) : new World(name);
+ var group = world.GetOrCreateSystemManaged();
+ world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
+ SetTick(world, tick);
+ return (world, group);
+ }
+
+ /// As , plus registers ONE ISystem and sorts. Drop-in for the per-file MakeWorld<T>.
+ public static (World world, SimulationSystemGroup group) Make(string name = "TestWorld", uint tick = 1, bool server = false)
+ where T : unmanaged, ISystem
+ {
+ var (world, group) = Make(name, tick, server);
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ group.SortSystems();
+ return (world, group);
+ }
+
+ /// Register an extra ISystem and re-sort (idempotent — final order is what matters).
+ public static void Add(World world, SimulationSystemGroup group) where T : unmanaged, ISystem
+ {
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ group.SortSystems();
+ }
+
+ /// The reconciled tick API: create NetworkTime if absent, else set it (a strict superset of both
+ /// hand-rolled variants — behaves like GetSingletonEntity() when present, and tolerates absence).
+ public static void SetTick(World world, uint tick)
+ {
+ var em = world.EntityManager;
+ using var q = em.CreateEntityQuery(typeof(NetworkTime));
+ Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
+ em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
+ }
+
+ // ---- domain builders (only for shapes that match exactly; per-file builders with extra components stay local) ----
+
+ public static Entity Player(EntityManager em, int netId = 1, byte region = RegionId.Base, float3 pos = default)
+ {
+ var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform));
+ em.SetComponentData(e, new GhostOwner { NetworkId = netId });
+ em.SetComponentData(e, new RegionTag { Region = region });
+ em.SetComponentData(e, LocalTransform.FromPosition(pos));
+ return e;
+ }
+
+ public static Entity Enemy(EntityManager em, float3 pos, float hp = 50f, byte region = RegionId.Base)
+ {
+ var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform), typeof(RegionTag), typeof(Health));
+ em.SetComponentData(e, LocalTransform.FromPosition(pos));
+ em.SetComponentData(e, new RegionTag { Region = region });
+ em.SetComponentData(e, new Health { Current = hp, Max = hp });
+ return e;
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/TestWorld.cs.meta b/Assets/_Project/Tests/EditMode/TestWorld.cs.meta
new file mode 100644
index 000000000..ff1c94bea
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/TestWorld.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 71d72296d0393c943a2184be97061d25
\ No newline at end of file