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)."); + } + } } }