diff --git a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs
new file mode 100644
index 000000000..dc2f1c6b5
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs
@@ -0,0 +1,145 @@
+using NUnit.Framework;
+using ProjectM.Server;
+using ProjectM.Simulation;
+using Unity.Collections;
+using Unity.Core;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Plain-Entities EditMode tests for the server-only — the home-base mining
+ /// field. A bare world is seeded with a NetworkTime singleton, a BaseAnchor (plot centred on origin), a
+ /// ResourceNode prefab (Prefab-tagged so it is excluded from the live count) and a BaseFieldSpawner +
+ /// BaseFieldRuntime. Pins: the first pass seeds the field to TargetCount with every node RegionTag{Base} +
+ /// ResourceId.Ore inside the [Inner,Outer] annulus; the cadence gate suppresses a respawn before the interval
+ /// elapses; and a depleted field tops back up to TargetCount once the interval passes (no economy soft-lock).
+ ///
+ public class BaseFieldSpawnSystemTests
+ {
+ static (World world, SimulationSystemGroup group, Entity spawner, Entity prefab) MakeWorld(string name, uint serverTick, int target, float inner, float outer, int interval)
+ {
+ var world = new World(name);
+ 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(serverTick) });
+
+ // Plot centred on origin: GridOrigin -16, dims 16, cell 2 => PlotCenter = (0,0,0).
+ var anchor = em.CreateEntity(typeof(BaseAnchor));
+ em.SetComponentData(anchor, new BaseAnchor
+ {
+ AnchorPos = float3.zero,
+ GridOrigin = new float3(-16f, 0f, -16f),
+ CellSize = 2f,
+ GridDims = new int2(16, 16),
+ });
+
+ // The node prefab: Prefab-tagged so the live-count query (and the system's) skip it.
+ var prefab = em.CreateEntity(typeof(Prefab), typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag), typeof(HitRadius));
+ em.SetComponentData(prefab, LocalTransform.FromPosition(float3.zero));
+ em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
+ em.SetComponentData(prefab, new RegionTag { Region = RegionId.Expedition });
+ em.SetComponentData(prefab, new HitRadius { Value = 1.2f });
+
+ var spawner = em.CreateEntity(typeof(BaseFieldSpawner), typeof(BaseFieldRuntime));
+ em.SetComponentData(spawner, new BaseFieldSpawner
+ {
+ Prefab = prefab,
+ TargetCount = target,
+ InnerRadius = inner,
+ OuterRadius = outer,
+ RespawnIntervalTicks = interval,
+ });
+ em.SetComponentData(spawner, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u });
+ return (world, group, spawner, prefab);
+ }
+
+ static Entity[] LiveNodes(EntityManager em)
+ {
+ // Default query options exclude Prefab + Disabled, so the prefab is not counted.
+ using var q = em.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
+ return q.ToEntityArray(Allocator.Temp).ToArray();
+ }
+
+ static void SetServerTick(EntityManager em, uint tick)
+ {
+ using var q = em.CreateEntityQuery(ComponentType.ReadWrite());
+ var nt = q.GetSingletonEntity();
+ em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(tick) });
+ }
+
+ [Test]
+ public void First_Pass_Seeds_Target_Count_Base_Ore_Nodes_In_Annulus()
+ {
+ var (world, group, _, _) = MakeWorld("BaseFieldSeed", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
+ using (world)
+ {
+ var em = world.EntityManager;
+
+ group.Update();
+
+ var nodes = LiveNodes(em);
+ Assert.AreEqual(8, nodes.Length, "The first pass seeds exactly TargetCount base nodes.");
+ foreach (var n in nodes)
+ {
+ Assert.AreEqual(RegionId.Base, em.GetComponentData(n).Region, "Base nodes are RegionTag.Base so RegionRelevancy keeps them for base players.");
+ Assert.AreEqual(ResourceId.Ore, em.GetComponentData(n).ResourceId, "Base nodes are Ore-only (the build currency).");
+ float2 xz = em.GetComponentData(n).Position.xz;
+ float r = math.length(xz);
+ Assert.GreaterOrEqual(r, 10f - 0.01f, "A node is no nearer than InnerRadius (clears the build plot).");
+ Assert.LessOrEqual(r, 20f + 0.01f, "A node is no farther than OuterRadius (stays reachable).");
+ }
+ }
+ }
+
+ [Test]
+ public void Does_Not_Respawn_Before_The_Interval_Elapses()
+ {
+ var (world, group, _, _) = MakeWorld("BaseFieldCadence", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
+ using (world)
+ {
+ var em = world.EntityManager;
+ group.Update(); // seeds 8, NextSpawnTick = 700
+
+ // Deplete 3 nodes.
+ var nodes = LiveNodes(em);
+ for (int i = 0; i < 3; i++)
+ em.DestroyEntity(nodes[i]);
+ Assert.AreEqual(5, LiveNodes(em).Length);
+
+ // Same tick (100 < 700): the cadence gate suppresses a refill.
+ group.Update();
+ Assert.AreEqual(5, LiveNodes(em).Length, "No top-up before RespawnIntervalTicks elapses.");
+ }
+ }
+
+ [Test]
+ public void Tops_Up_To_Target_After_Depletion_Once_Interval_Passes()
+ {
+ var (world, group, _, _) = MakeWorld("BaseFieldTopUp", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
+ using (world)
+ {
+ var em = world.EntityManager;
+ group.Update(); // seeds 8, NextSpawnTick = 700
+
+ var nodes = LiveNodes(em);
+ for (int i = 0; i < 3; i++)
+ em.DestroyEntity(nodes[i]);
+ Assert.AreEqual(5, LiveNodes(em).Length);
+
+ // Advance past the interval (800 > 700): refill the 3-node deficit back to TargetCount.
+ SetServerTick(em, 800);
+ group.Update();
+ Assert.AreEqual(8, LiveNodes(em).Length, "A depleted field tops back up to TargetCount (no economy soft-lock).");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta
new file mode 100644
index 000000000..e3c7c5a1c
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a7a8c39aa67d45d4586a269f136999c5
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs
new file mode 100644
index 000000000..792de4da2
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs
@@ -0,0 +1,57 @@
+using NUnit.Framework;
+using ProjectM.Server;
+using ProjectM.Simulation;
+using Unity.Core;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.Transforms;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Regression test for the teardown region-filter. When the last player
+ /// leaves the expedition the field is cleared — but that teardown must destroy ONLY RegionTag{Expedition}
+ /// nodes, never the permanent RegionTag{Base} home-base mining field. Before the fix the unfiltered teardown
+ /// wiped every ResourceNode on the empty edge (a despawn storm beside base players that broke the core loop).
+ ///
+ public class ExpeditionFieldTeardownTests
+ {
+ [Test]
+ public void Expedition_Empty_Edge_Destroys_Only_Expedition_Nodes_Base_Field_Survives()
+ {
+ var world = new World("ExpeditionTeardown");
+ using (world)
+ {
+ var group = world.GetOrCreateSystemManaged();
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ group.SortSystems();
+ world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
+ var em = world.EntityManager;
+
+ // Cycle director: was occupied last tick, nobody out there now => the occupied->empty edge fires.
+ var cycle = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
+ em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Calm, CycleNumber = 1 });
+ em.SetComponentData(cycle, new CycleRuntime { PrevExpeditionOccupied = 1 });
+
+ // Spawner singleton (required); null prefab so the spawn branch is inert.
+ var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner));
+ em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = Entity.Null, Count = 5, Radius = 10f });
+
+ var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
+ em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20, 0, 0)));
+ em.SetComponentData(baseNode, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f });
+ em.SetComponentData(baseNode, new RegionTag { Region = RegionId.Base });
+
+ var expNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
+ em.SetComponentData(expNode, LocalTransform.FromPosition(new float3(1020, 0, 0)));
+ em.SetComponentData(expNode, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
+ em.SetComponentData(expNode, new RegionTag { Region = RegionId.Expedition });
+
+ group.Update();
+
+ Assert.IsTrue(em.Exists(baseNode), "The permanent base mining field survives the expedition teardown.");
+ Assert.IsFalse(em.Exists(expNode), "Only the expedition node is cleared when the last player leaves.");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta
new file mode 100644
index 000000000..4e6b5916c
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 269ed0f5b1c5cb6418682ccf05db45dd
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
index d7307acdb..6735be609 100644
--- a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
+++ b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
@@ -429,5 +429,64 @@ namespace ProjectM.Tests
}
}
+ // ---- any-attack harvest (MC-4 melee mines too) ----
+
+ static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
+ {
+ var buf = em.GetBuffer(ledger);
+ for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == itemId) return buf[i].Count;
+ return 0;
+ }
+
+ [Test]
+ public void Cleave_Harvests_A_Base_Node_To_The_Shared_Ledger()
+ {
+ var (world, group) = MakeWorld("MeleeHarvestBase", 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var ledger = em.CreateEntity(typeof(ResourceLedger));
+ em.AddBuffer(ledger);
+ var p = MakePlayer(em, new float2(0, 1)); // at origin, facing +Z
+ var node = em.CreateEntity();
+ em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2))); // in the light cone
+ em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f });
+ em.AddComponentData(node, new RegionTag { Region = RegionId.Base });
+ Press(em, p);
+
+ group.Update();
+
+ Assert.AreEqual(5, LedgerCount(em, ledger, ResourceId.Ore), "a base-node melee hit credits the shared ledger (the build pool).");
+ Assert.AreEqual(25, em.GetComponentData(node).Remaining, "the node is depleted by HarvestPerHit (written back for the chip VFX).");
+ }
+ }
+
+ [Test]
+ public void Cleave_Harvests_An_Expedition_Node_To_Personal_Inventory_Not_The_Ledger()
+ {
+ var (world, group) = MakeWorld("MeleeHarvestExp", 100, server: true);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var ledger = em.CreateEntity(typeof(ResourceLedger));
+ em.AddBuffer(ledger);
+ var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7
+ em.AddComponent(p);
+ em.AddBuffer(p);
+ var node = em.CreateEntity();
+ em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2)));
+ em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
+ em.AddComponentData(node, new RegionTag { Region = RegionId.Expedition });
+ Press(em, p);
+
+ group.Update();
+
+ var inv = em.GetBuffer(p);
+ Assert.AreEqual(5, InventoryMath.CountOf(inv, ResourceId.Aether), "an expedition-node melee hit lands in the swinging player's PERSONAL inventory.");
+ Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "an expedition harvest does NOT credit the shared base ledger (DR-026 personal haul).");
+ Assert.AreEqual(25, em.GetComponentData(node).Remaining, "the node is still depleted.");
+ }
+ }
+
}
}
diff --git a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs
index 58a69a879..1587a6fc3 100644
--- a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs
+++ b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs
@@ -151,5 +151,52 @@ namespace ProjectM.Tests
"The siege clock resets after collapse.");
}
}
+
+ [Test]
+ public void Schedule_First_Pass_Seeds_NextTick_Without_Firing()
+ {
+ var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var config = DefaultConfig();
+ config.PostExpeditionEnabled = 0;
+ config.ScheduleEnabled = 1;
+ config.ScheduleIntervalTicks = 100;
+ var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config);
+
+ group.Update();
+
+ var ts = em.GetComponentData(dir);
+ Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100).");
+ Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately.");
+ }
+ }
+
+ [Test]
+ public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition()
+ {
+ var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var config = DefaultConfig();
+ config.PostExpeditionEnabled = 0; // isolate the schedule source
+ config.ScheduleEnabled = 1;
+ config.ScheduleIntervalTicks = 100;
+ config.ScheduleSizePerWave = 0;
+ config.SizeBase = 5;
+ config.PostExpeditionDelayTicks = 10;
+ // NextScheduledTick 300 <= now 400 => the scheduled siege is due.
+ var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config);
+
+ group.Update();
+
+ var ts = em.GetComponentData(dir);
+ Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip.");
+ Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10).");
+ Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100).");
+ }
+ }
}
}