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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 19:14:58 -07:00
parent 2da29783fd
commit 44da26cdf6
2 changed files with 177 additions and 17 deletions
@@ -9,11 +9,13 @@ namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="FabricatorProductionSystem"/> — the recipe machine
/// that consumes <c>InAmount</c> of its input resource per run from its OWN <see cref="MachineInput"/> buffer and
/// deposits <c>OutAmount * runs</c> into the GLOBAL resource ledger (resolved via <see cref="ResourceLedger"/>,
/// never GetSingleton&lt;StorageEntry&gt;). 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 <c>InAmount</c> of its input resource per run and deposits <c>OutAmount * runs</c> into the GLOBAL
/// resource ledger (resolved via <see cref="ResourceLedger"/>, never GetSingleton&lt;StorageEntry&gt;). 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 (<c>InputFromLedger != 0</c>): 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.
/// </summary>
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<MachineInput>(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<StorageEntry>(ledger);
buf.Add(new StorageEntry { ItemId = itemId, Count = count });
}
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(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.");
}
}
}
}