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