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 recipe machine /// that consumes InAmount of its input resource per run and deposits OutAmount * runs into the GLOBAL /// resource ledger (resolved via , never GetSingleton<StorageEntry>). Pins: it /// INITIALIZES on first touch without producing; it is strictly INPUT-LIMITED (runs = min(cycles, affordable) — no /// mint-from-nothing); it consumes exactly InAmount*runs; and catch-up after skipped ticks awards the exact capped /// amount. EB-2 adds the LEDGER-FED mode (InputFromLedger != 0): input is withdrawn from the shared ledger /// (read LIVE inside the loop so two ledger-fed machines split a finite pool correctly) instead of MachineInput — /// this is how mined Ore becomes turret-ammo Charge. /// public class FabricatorProductionSystemTests { static (World world, SimulationSystemGroup group, Entity ledger) 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); var em = world.EntityManager; var ledger = em.CreateEntity(typeof(ResourceLedger)); em.AddBuffer(ledger); return (world, group, ledger); } 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 MakeFabricator(EntityManager em, byte inId, int inAmt, byte outId, int outAmt, int periodTicks, int seedInput, byte fromLedger = 0) { var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Fabricator, NextTick = 0u, LastProcessedTick = 0u, }); em.AddComponentData(e, new Fabricator { InResourceId = inId, InAmount = inAmt, OutResourceId = outId, OutAmount = outAmt, PeriodTicks = periodTicks, InputFromLedger = fromLedger, }); var input = em.AddBuffer(e); if (seedInput > 0) input.Add(new MachineInput { ResourceId = inId, Count = seedInput }); em.AddBuffer(e); return e; } static void SeedLedger(EntityManager em, Entity ledger, ushort itemId, int count) { var buf = em.GetBuffer(ledger); buf.Add(new StorageEntry { ItemId = itemId, Count = count }); } 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; } 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; } [Test] public void First_Update_Initializes_Without_Producing() { var (world, group, ledger) = MakeWorld("FabInit", serverTick: 100); using (world) { var em = world.EntityManager; var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 10); group.Update(); Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "First touch only initializes (no production)."); Assert.AreEqual(10, InputOf(em, f, ResourceId.Ore), "No input is consumed during init."); var ps = em.GetComponentData(f); Assert.AreNotEqual(0u, ps.LastProcessedTick); Assert.AreNotEqual(0u, ps.NextTick); } } [Test] public void Produces_One_Run_Per_Period_When_Input_Is_Available() { var (world, group, ledger) = MakeWorld("FabRun", serverTick: 100); using (world) { var em = world.EntityManager; var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 10); group.Update(); // init (NextTick -> 130) SetServerTick(world, 130); group.Update(); // one period elapsed, input affords it -> 1 run Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether), "One run deposits OutAmount into the ledger."); Assert.AreEqual(8, InputOf(em, f, ResourceId.Ore), "One run consumes InAmount from the input buffer (10 - 2)."); } } [Test] public void Is_Input_Limited_No_Mint_From_Empty_Slot() { var (world, group, ledger) = MakeWorld("FabStarved", serverTick: 100); using (world) { var em = world.EntityManager; // Empty input slot: even with periods elapsed, affordable == 0 -> runs == 0 -> nothing minted. var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 0); group.Update(); // init SetServerTick(world, 250); // plenty of periods elapsed group.Update(); Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "A starved fabricator mints nothing — production is strictly input-limited."); Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "No phantom input appears."); } } [Test] public void Runs_Are_Clamped_To_Affordable_Input() { var (world, group, ledger) = MakeWorld("FabAfford", serverTick: 100); using (world) { var em = world.EntityManager; // 5 periods become due (150 ticks / 30), but only 3 runs are affordable (7 input / 2 per run = 3). var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 7); group.Update(); // init at 100 SetServerTick(world, 250); // floor(150/30) = 5 cycles due group.Update(); Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether), "runs = min(cyclesDue=5, affordable=3) = 3 — output is clamped to available input."); Assert.AreEqual(1, InputOf(em, f, ResourceId.Ore), "3 runs consume 6 of 7 input, leaving 1."); } } [Test] public void CatchUp_Awards_Exact_Multiple_When_Input_Allows() { var (world, group, ledger) = MakeWorld("FabCatchUp", serverTick: 100); using (world) { var em = world.EntityManager; var f = MakeFabricator(em, ResourceId.Ore, inAmt: 1, outId: ResourceId.Biomass, outAmt: 2, periodTicks: 30, seedInput: 1000); group.Update(); // init at 100 SetServerTick(world, 250); // floor(150/30) = 5 cycles, all affordable group.Update(); Assert.AreEqual(10, LedgerCount(em, ledger, ResourceId.Biomass), "5 runs * OutAmount(2) = 10 deposited."); Assert.AreEqual(995, InputOf(em, f, ResourceId.Ore), "5 runs * InAmount(1) = 5 consumed (1000 - 5)."); } } // ---- EB-2: ledger-fed Fabricator (mined Ore -> turret-ammo Charge) ---- [Test] public void LedgerFed_Withdraws_Input_From_Ledger_And_Deposits_Output() { var (world, group, ledger) = MakeWorld("FabLedgerFed", serverTick: 100); using (world) { var em = world.EntityManager; SeedLedger(em, ledger, ResourceId.Ore, 10); // InputFromLedger=1, MachineInput intentionally EMPTY (seedInput:0) — input must come from the ledger. var f = MakeFabricator(em, ResourceId.Ore, inAmt: 1, outId: ResourceId.Charge, outAmt: 3, periodTicks: 30, seedInput: 0, fromLedger: 1); group.Update(); // init (NextTick -> 130) SetServerTick(world, 130); group.Update(); // one period: withdraw 1 Ore FROM the ledger, deposit 3 Charge Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Charge), "One run deposits OutAmount Charge into the ledger."); Assert.AreEqual(9, LedgerCount(em, ledger, ResourceId.Ore), "One run withdraws InAmount Ore FROM the ledger (10 - 1)."); Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "A ledger-fed Fabricator never touches its MachineInput buffer."); } } [Test] public void Two_LedgerFed_Fabricators_Split_A_Finite_Ledger_Via_The_Live_Read() { // 3 Ore in the ledger, two ledger-fed Fabricators each needing 2 Ore/run. The system reads the ledger // LIVE inside the loop, so the first withdraws 2 (Ore 3->1) and the second sees only 1 (floor(1/2)=0) and // starves. A HOISTED/stale read would let both see 3, both run, and drive the ledger negative. var (world, group, ledger) = MakeWorld("FabLedgerSplit", serverTick: 100); using (world) { var em = world.EntityManager; SeedLedger(em, ledger, ResourceId.Ore, 3); MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1); MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1); group.Update(); // init both (NextTick -> 130) SetServerTick(world, 130); group.Update(); // one period: only ONE fab can afford 2 Ore from the shared pool Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Charge), "Only one of the two ledger-fed Fabricators affords a run from the shared 3 Ore — the live read prevents a double-spend."); Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Ore), "2 of 3 Ore withdrawn; the ledger never goes negative."); } } [Test] public void LedgerFed_Runs_Are_Clamped_To_Affordable_Ledger_Under_CatchUp() { // 5 periods become due (150 ticks / 30), but the shared ledger only affords 3 runs (7 Ore / 2 per run). // Pins the LEDGER branch of runs = min(cyclesDue, affordable) as the strict binding minimum under catch-up. var (world, group, ledger) = MakeWorld("FabLedgerCatchUp", serverTick: 100); using (world) { var em = world.EntityManager; SeedLedger(em, ledger, ResourceId.Ore, 7); var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1); group.Update(); // init at 100 SetServerTick(world, 250); // floor(150/30) = 5 cycles due group.Update(); Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Charge), "runs = min(cyclesDue=5, ledgerAffordable=floor(7/2)=3) = 3 — clamped by the LEDGER, not the cycle count."); Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Ore), "3 runs withdraw 6 of 7 Ore from the ledger, leaving 1."); Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "Ledger-fed mode never touches MachineInput."); } } } }