From 44da26cdf62b9d6cce875a791139958892cbbc3e Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 12 Jun 2026 19:14:58 -0700 Subject: [PATCH] Tests: EB-2 Charge spend + ledger-fed Fabricator (318/318 EditMode) - TurretFireSystem: seed a Charge pool for existing tests; add soft-fail-when-dry, consume-one-Charge-per-shot, two-turrets-share-a-finite-pool. - FabricatorProductionSystem: ledger-fed withdraw/deposit, two machines split via the live in-loop read, and a catch-up affordability-clamp regression pin. See DR-033. Co-Authored-By: Claude Opus 4.8 --- .../FabricatorProductionSystemTests.cs | 92 ++++++++++++++-- .../Tests/EditMode/TurretFireSystemTests.cs | 102 ++++++++++++++++-- 2 files changed, 177 insertions(+), 17 deletions(-) diff --git a/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs index d55434cac..81b17ce99 100644 --- a/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs @@ -9,11 +9,13 @@ namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — the recipe machine - /// that consumes InAmount of its input resource per run from its OWN buffer 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 when the input slot is empty); it consumes - /// exactly InAmount*runs from the input buffer; and catch-up after skipped ticks awards the exact capped amount. + /// 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 { @@ -39,7 +41,8 @@ namespace ProjectM.Tests 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) + 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 }); @@ -56,6 +59,7 @@ namespace ProjectM.Tests OutResourceId = outId, OutAmount = outAmt, PeriodTicks = periodTicks, + InputFromLedger = fromLedger, }); var input = em.AddBuffer(e); if (seedInput > 0) @@ -64,6 +68,12 @@ namespace ProjectM.Tests 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); @@ -176,5 +186,75 @@ namespace ProjectM.Tests 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."); + } + } } } diff --git a/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs index 802f90ccc..38edd5f8d 100644 --- a/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs @@ -11,15 +11,21 @@ namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only (hitscan defense turret). - /// A bare world is seeded with a NetworkTime singleton, a turret (PlacedStructure + Turret + RegionTag - /// + LocalTransform) and Husks (Health + EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The - /// system snapshots living Husks, fires at the nearest in-range one in its OWN region on the - /// PlacedStructure.NextTick cooldown, and appends a DamageEvent{SourceNetworkId=-1}. These tests - /// pin range filtering, region gating, and the wrap-safe cooldown. + /// A bare world is seeded with a NetworkTime singleton, a Charge pool (the + /// turret's EB-2 ammo), a turret (PlacedStructure + Turret + RegionTag + LocalTransform) and Husks (Health + + /// EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The system snapshots living Husks, fires at + /// the nearest in-range one in its OWN region on the PlacedStructure.NextTick cooldown — but ONLY when + /// it can withdraw Charge from the ledger; out of Charge = + /// soft-fail (no shot, no cooldown burn, so it fires the instant Charge returns). These tests pin range + /// filtering, region gating, the wrap-safe cooldown, and the EB-2 Charge spend / soft-fail. /// public class TurretFireSystemTests { - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + // Range/region/cooldown tests never need to run dry; the spend tests pass an explicit small pool. + const int AmpleCharge = 1_000_000; + + static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld( + string name, uint serverTick, int charge = AmpleCharge) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); @@ -27,7 +33,12 @@ namespace ProjectM.Tests group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetServerTick(world, serverTick); - return (world, group); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + var buf = em.AddBuffer(ledger); + if (charge > 0) + buf.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = charge }); + return (world, group, ledger); } static void SetServerTick(World world, uint tick) @@ -59,10 +70,18 @@ namespace ProjectM.Tests return e; } + static int LedgerCharge(EntityManager em, Entity ledger) + { + var buf = em.GetBuffer(ledger); + for (int i = 0; i < buf.Length; i++) + if (buf[i].ItemId == ResourceId.Charge) return buf[i].Count; + return 0; + } + [Test] public void Turret_Fires_At_InRange_Husk_In_Its_Region() { - var (world, group) = MakeWorld("TurretFireWorld", serverTick: 200); + var (world, group, _) = MakeWorld("TurretFireWorld", serverTick: 200); using (world) { var em = world.EntityManager; @@ -83,7 +102,7 @@ namespace ProjectM.Tests [Test] public void Turret_Ignores_Husk_Out_Of_Range() { - var (world, group) = MakeWorld("TurretRangeWorld", serverTick: 200); + var (world, group, _) = MakeWorld("TurretRangeWorld", serverTick: 200); using (world) { var em = world.EntityManager; @@ -99,7 +118,7 @@ namespace ProjectM.Tests [Test] public void Turret_Ignores_Husk_In_A_Different_Region() { - var (world, group) = MakeWorld("TurretRegionWorld", serverTick: 200); + var (world, group, _) = MakeWorld("TurretRegionWorld", serverTick: 200); using (world) { var em = world.EntityManager; @@ -116,7 +135,7 @@ namespace ProjectM.Tests [Test] public void Turret_Respects_Cooldown_Then_Fires_Again() { - var (world, group) = MakeWorld("TurretCooldownWorld", serverTick: 200); + var (world, group, _) = MakeWorld("TurretCooldownWorld", serverTick: 200); using (world) { var em = world.EntityManager; @@ -135,5 +154,66 @@ namespace ProjectM.Tests Assert.AreEqual(2, em.GetBuffer(husk).Length, "Fires again once the cooldown elapses."); } } + + // ---- EB-2: turret ammo (Charge) spend + soft-fail ---- + + [Test] + public void Turret_Out_Of_Charge_Soft_Fails_No_Shot_No_Cooldown_Burn() + { + var (world, group, ledger) = MakeWorld("TurretNoChargeWorld", serverTick: 200, charge: 0); + using (world) + { + var em = world.EntityManager; + var turret = MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); + var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f); + + group.Update(); + + Assert.AreEqual(0, em.GetBuffer(husk).Length, + "With an empty Charge pool the turret cannot fire (soft-fail)."); + Assert.AreEqual(0u, em.GetComponentData(turret).NextTick, + "A soft-failed shot burns no cooldown — the turret stays ready for the instant Charge returns."); + Assert.AreEqual(0, LedgerCharge(em, ledger), "No Charge is consumed when no shot is fired."); + } + } + + [Test] + public void Turret_Consumes_One_Charge_Per_Shot() + { + var (world, group, ledger) = MakeWorld("TurretSpendWorld", serverTick: 200, charge: 5); + using (world) + { + var em = world.EntityManager; + MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); + var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f); + + group.Update(); + + Assert.AreEqual(1, em.GetBuffer(husk).Length, "A funded turret fires."); + Assert.AreEqual(5 - Tuning.TurretChargeCostPerShot, LedgerCharge(em, ledger), + "Exactly TurretChargeCostPerShot Charge is withdrawn for the shot."); + } + } + + [Test] + public void Two_Turrets_Share_A_Finite_Charge_Pool_Second_Soft_Fails() + { + // One Charge, two turrets, one Husk: the first turret (query/creation order) spends the last Charge and + // fires; the second withdraws 0 and soft-fails. The Husk takes exactly one hit and the pool empties. + var (world, group, ledger) = MakeWorld("TurretSharedPoolWorld", serverTick: 200, charge: 1); + using (world) + { + var em = world.EntityManager; + MakeTurret(em, new float3(-2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); + MakeTurret(em, new float3(2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); + var husk = MakeHusk(em, new float3(0, 1, 0), RegionId.Base, health: 500f); + + group.Update(); + + Assert.AreEqual(1, em.GetBuffer(husk).Length, + "A single Charge funds exactly one of the two turrets this tick."); + Assert.AreEqual(0, LedgerCharge(em, ledger), "The shared Charge pool is drained to zero."); + } + } } }