using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — the transport step /// that PULLS resources off an adjacent upstream onto an empty belt, advances a held /// exactly one cell per tick along its , and DEPOSITS /// into a downstream machine's sink. Determinism (Y-junction tie-break, stall-no-loss, /// shuffle-invariance) is exhaustively proven world-free in ; these tests pin the /// SYSTEM wiring: snapshot -> ResolveMoves -> apply (ConveyorItem enable/disable bit + sink deposit + machine pull), /// plus the per-conveyor period gate (init on first touch, then move on the production cadence). /// public class ConveyorTransportSystemTests { static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetServerTick(world, serverTick); return (world, group); } static void SetServerTick(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) }); } static Entity MakeConveyor(EntityManager em, int2 cell, byte dir, int periodTicks, byte itemRes, int itemCnt) { var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Conveyor, Cell = cell, NextTick = 0u, LastProcessedTick = 0u, }); em.AddComponentData(e, new Conveyor { Direction = dir, PeriodTicks = periodTicks }); em.AddComponentData(e, new ConveyorItem { ResourceId = itemRes, Count = itemCnt }); em.SetComponentEnabled(e, itemCnt > 0); // baked DISABLED unless carrying an item return e; } static Entity MakeHarvesterOutput(EntityManager em, int2 cell, byte resourceId, int count) { // A minimal upstream producer: a PlacedStructure at a cell with a populated MachineOutput buffer. var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Harvester, Cell = cell, NextTick = 0u, LastProcessedTick = 1u }); var output = em.AddBuffer(e); if (count > 0) output.Add(new MachineOutput { ResourceId = resourceId, Count = count }); return e; } static Entity MakeFabricatorInput(EntityManager em, int2 cell) { // A minimal downstream sink: a PlacedStructure at a cell with an (empty) MachineInput buffer. var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Fabricator, Cell = cell, NextTick = 0u, LastProcessedTick = 1u }); em.AddBuffer(e); return e; } static int OutputOf(EntityManager em, Entity machine, byte resourceId) { var buf = em.GetBuffer(machine); int total = 0; for (int i = 0; i < buf.Length; i++) if (buf[i].ResourceId == resourceId) total += buf[i].Count; return total; } static int InputOf(EntityManager em, Entity machine, byte resourceId) { var buf = em.GetBuffer(machine); int total = 0; for (int i = 0; i < buf.Length; i++) if (buf[i].ResourceId == resourceId) total += buf[i].Count; return total; } /// Ticks the system past its per-conveyor init gate so subsequent updates actually transport. static void InitThenAdvance(World world, SimulationSystemGroup group, uint initTick, uint moveTick) { SetServerTick(world, initTick); group.Update(); // first touch: init the conveyor period gate (no move) SetServerTick(world, moveTick); group.Update(); // a period later: the transport step runs } [Test] public void Conveyor_Pulls_From_Adjacent_Upstream_MachineOutput_When_Empty() { var (world, group) = MakeWorld("ConveyorPull", serverTick: 100); using (world) { var em = world.EntityManager; // Harvester output at (0,0); an EMPTY belt at (1,0) facing +X. The belt should pull one unit from the // upstream output (the harvester's cell (0,0) is belt.cell - DirOffset = (1,0)-(1,0) = (0,0)). var harvester = MakeHarvesterOutput(em, new int2(0, 0), ResourceId.Ore, count: 4); var belt = MakeConveyor(em, new int2(1, 0), dir: 0 /*+X*/, periodTicks: 30, itemRes: 0, itemCnt: 0); InitThenAdvance(world, group, initTick: 100, moveTick: 130); Assert.IsTrue(em.IsComponentEnabled(belt), "An empty belt adjacent to an upstream output pulls an item (ConveyorItem enabled)."); var item = em.GetComponentData(belt); Assert.AreEqual(ResourceId.Ore, item.ResourceId, "The pulled item carries the upstream resource id."); Assert.AreEqual(3, OutputOf(em, harvester, ResourceId.Ore), "Exactly one unit is pulled off the upstream output (4 -> 3)."); } } [Test] public void Item_Advances_Exactly_One_Cell_Along_Direction() { var (world, group) = MakeWorld("ConveyorAdvance", serverTick: 100); using (world) { var em = world.EntityManager; // Two +X belts (0,0)->(1,0). The source carries an item; the destination belt is empty. After a tick // the item should be on the destination belt and the source belt empty. var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Biomass, itemCnt: 1); var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0); InitThenAdvance(world, group, initTick: 100, moveTick: 130); Assert.IsFalse(em.IsComponentEnabled(src), "The item leaves the source belt (now empty)."); Assert.IsTrue(em.IsComponentEnabled(dst), "The item arrives on the next belt (exactly one cell along +X)."); Assert.AreEqual(ResourceId.Biomass, em.GetComponentData(dst).ResourceId, "The carried resource id is preserved across the move."); } } [Test] public void Item_Deposits_Into_Downstream_Machine_Input_Sink() { var (world, group) = MakeWorld("ConveyorSink", serverTick: 100); using (world) { var em = world.EntityManager; // A +X belt at (0,0) carrying an item; a fabricator input at (1,0) is the sink. The item should be // deposited into the machine's MachineInput and removed from the belt. var belt = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1); var fab = MakeFabricatorInput(em, new int2(1, 0)); InitThenAdvance(world, group, initTick: 100, moveTick: 130); Assert.AreEqual(1, InputOf(em, fab, ResourceId.Ore), "The item is deposited into the downstream machine's input."); Assert.IsFalse(em.IsComponentEnabled(belt), "The belt is empty after handing its item to the sink."); } } [Test] public void Does_Not_Transport_While_Cooling_Down() { var (world, group) = MakeWorld("ConveyorCooling", serverTick: 100); using (world) { var em = world.EntityManager; var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1); var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0); SetServerTick(world, 100); group.Update(); // init (NextTick -> 130) SetServerTick(world, 115); group.Update(); // 115 < 130 -> belt is still cooling, no move Assert.IsTrue(em.IsComponentEnabled(src), "The item stays put while the belt is cooling down."); Assert.IsFalse(em.IsComponentEnabled(dst), "Nothing arrives before the belt's period elapses."); } } } }