using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — the fixed-yield /// generator that deposits Yield * cycles of its resource into its OWN buffer /// on a deterministic period. Pins the SINGLE GATED catch-up path from the M7 contract: a never-processed machine /// (LastProcessedTick==0) only INITIALIZES on its first touch (no production), then produces exactly one yield per /// elapsed period, catch-up after skipped ticks awards the exact (capped) amount, and a cooling machine produces /// nothing. Output stays in the machine's local buffer (server-only, no GhostField) — the conveyor pulls it later. /// public class HarvesterProductionSystemTests { 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 MakeHarvester(EntityManager em, byte resourceId, int yield, int periodTicks) { var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Harvester, NextTick = 0u, LastProcessedTick = 0u, // never processed -> first update only initializes }); em.AddComponentData(e, new Harvester { ResourceId = resourceId, Yield = yield, PeriodTicks = periodTicks }); 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; } [Test] public void First_Update_Initializes_Without_Producing() { var (world, group) = MakeWorld("HarvesterInit", serverTick: 100); using (world) { var em = world.EntityManager; var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30); group.Update(); Assert.AreEqual(0, OutputOf(em, h, ResourceId.Aether), "A never-processed machine only initializes on its first touch (no production)."); var ps = em.GetComponentData(h); Assert.AreNotEqual(0u, ps.LastProcessedTick, "Init stamps LastProcessedTick to 'now'."); Assert.AreNotEqual(0u, ps.NextTick, "Init stamps the next production tick (now + period)."); } } [Test] public void Produces_One_Yield_After_Exactly_One_Period() { var (world, group) = MakeWorld("HarvesterOnePeriod", serverTick: 100); using (world) { var em = world.EntityManager; var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30); group.Update(); // tick 100: init (NextTick -> 130) SetServerTick(world, 130); group.Update(); // tick 130: one period elapsed -> +5 Assert.AreEqual(5, OutputOf(em, h, ResourceId.Aether), "One elapsed period yields exactly Yield."); } } [Test] public void Does_Not_Produce_While_Cooling_Down() { var (world, group) = MakeWorld("HarvesterCooling", serverTick: 100); using (world) { var em = world.EntityManager; var h = MakeHarvester(em, ResourceId.Ore, yield: 5, periodTicks: 30); group.Update(); // init (NextTick -> 130) SetServerTick(world, 115); group.Update(); // 115 < 130 -> still cooling Assert.AreEqual(0, OutputOf(em, h, ResourceId.Ore), "No production before the period elapses."); } } [Test] public void CatchUp_Awards_Exact_Multiple_For_Skipped_Periods() { var (world, group) = MakeWorld("HarvesterCatchUp", serverTick: 100); using (world) { var em = world.EntityManager; var h = MakeHarvester(em, ResourceId.Biomass, yield: 5, periodTicks: 30); group.Update(); // init at 100 (LastProcessedTick -> 100) SetServerTick(world, 250); // 150 ticks elapsed -> floor(150/30) = 5 cycles group.Update(); Assert.AreEqual(25, OutputOf(em, h, ResourceId.Biomass), "Catch-up awards Yield * floor(elapsed/period) = 5 * 5 = 25 (skipped ticks are not lost)."); } } [Test] public void CatchUp_Is_Capped_At_MaxProductionCatchup() { var (world, group) = MakeWorld("HarvesterCap", serverTick: 100); using (world) { var em = world.EntityManager; var h = MakeHarvester(em, ResourceId.Aether, yield: 1, periodTicks: 1); group.Update(); // init at 100 SetServerTick(world, 100u + 5_000_000u); // an absurd gap group.Update(); Assert.AreEqual(Tuning.MaxProductionCatchup, OutputOf(em, h, ResourceId.Aether), "A long gap is capped at MaxProductionCatchup cycles (no unbounded mint)."); } } } }