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