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> /// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="FabricatorProductionSystem"/> — the recipe machine /// 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 /// that consumes <c>InAmount</c> of its input resource per run and deposits <c>OutAmount * runs</c> into the GLOBAL
/// deposits <c>OutAmount * runs</c> into the GLOBAL resource ledger (resolved via <see cref="ResourceLedger"/>, /// resource ledger (resolved via <see cref="ResourceLedger"/>, never GetSingleton&lt;StorageEntry&gt;). Pins: it
/// never GetSingleton&lt;StorageEntry&gt;). Pins: it INITIALIZES on first touch without producing; it is strictly /// INITIALIZES on first touch without producing; it is strictly INPUT-LIMITED (runs = min(cycles, affordable) — no
/// INPUT-LIMITED (runs = min(cycles, affordable) — no mint-from-nothing when the input slot is empty); it consumes /// mint-from-nothing); it consumes exactly InAmount*runs; and catch-up after skipped ticks awards the exact capped
/// exactly InAmount*runs from the input buffer; and catch-up after skipped ticks awards the exact capped amount. /// 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> /// </summary>
public class FabricatorProductionSystemTests public class FabricatorProductionSystemTests
{ {
@@ -39,7 +41,8 @@ namespace ProjectM.Tests
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); 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(); var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
@@ -56,6 +59,7 @@ namespace ProjectM.Tests
OutResourceId = outId, OutResourceId = outId,
OutAmount = outAmt, OutAmount = outAmt,
PeriodTicks = periodTicks, PeriodTicks = periodTicks,
InputFromLedger = fromLedger,
}); });
var input = em.AddBuffer<MachineInput>(e); var input = em.AddBuffer<MachineInput>(e);
if (seedInput > 0) if (seedInput > 0)
@@ -64,6 +68,12 @@ namespace ProjectM.Tests
return e; 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) static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{ {
var buf = em.GetBuffer<StorageEntry>(ledger); 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)."); 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.");
}
}
} }
} }
@@ -11,15 +11,21 @@ namespace ProjectM.Tests
{ {
/// <summary> /// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="TurretFireSystem"/> (hitscan defense turret). /// Plain-Entities EditMode tests for the server-only <see cref="TurretFireSystem"/> (hitscan defense turret).
/// A bare world is seeded with a <c>NetworkTime</c> singleton, a turret (PlacedStructure + Turret + RegionTag /// A bare world is seeded with a <c>NetworkTime</c> singleton, a <see cref="ResourceLedger"/> Charge pool (the
/// + LocalTransform) and Husks (Health + EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The /// turret's EB-2 ammo), a turret (PlacedStructure + Turret + RegionTag + LocalTransform) and Husks (Health +
/// system snapshots living Husks, fires at the nearest in-range one in its OWN region on the /// EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The system snapshots living Husks, fires at
/// <c>PlacedStructure.NextTick</c> cooldown, and appends a <c>DamageEvent{SourceNetworkId=-1}</c>. These tests /// the nearest in-range one in its OWN region on the <c>PlacedStructure.NextTick</c> cooldown — but ONLY when
/// pin range filtering, region gating, and the wrap-safe cooldown. /// it can withdraw <see cref="Tuning.TurretChargeCostPerShot"/> 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.
/// </summary> /// </summary>
public class TurretFireSystemTests 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 world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>(); var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
@@ -27,7 +33,12 @@ namespace ProjectM.Tests
group.SortSystems(); group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick); SetServerTick(world, serverTick);
return (world, group); var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
var buf = em.AddBuffer<StorageEntry>(ledger);
if (charge > 0)
buf.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = charge });
return (world, group, ledger);
} }
static void SetServerTick(World world, uint tick) static void SetServerTick(World world, uint tick)
@@ -59,10 +70,18 @@ namespace ProjectM.Tests
return e; return e;
} }
static int LedgerCharge(EntityManager em, Entity ledger)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Charge) return buf[i].Count;
return 0;
}
[Test] [Test]
public void Turret_Fires_At_InRange_Husk_In_Its_Region() 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) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
@@ -83,7 +102,7 @@ namespace ProjectM.Tests
[Test] [Test]
public void Turret_Ignores_Husk_Out_Of_Range() public void Turret_Ignores_Husk_Out_Of_Range()
{ {
var (world, group) = MakeWorld("TurretRangeWorld", serverTick: 200); var (world, group, _) = MakeWorld("TurretRangeWorld", serverTick: 200);
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
@@ -99,7 +118,7 @@ namespace ProjectM.Tests
[Test] [Test]
public void Turret_Ignores_Husk_In_A_Different_Region() 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) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
@@ -116,7 +135,7 @@ namespace ProjectM.Tests
[Test] [Test]
public void Turret_Respects_Cooldown_Then_Fires_Again() public void Turret_Respects_Cooldown_Then_Fires_Again()
{ {
var (world, group) = MakeWorld("TurretCooldownWorld", serverTick: 200); var (world, group, _) = MakeWorld("TurretCooldownWorld", serverTick: 200);
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
@@ -135,5 +154,66 @@ namespace ProjectM.Tests
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(husk).Length, "Fires again once the cooldown elapses."); Assert.AreEqual(2, em.GetBuffer<DamageEvent>(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<DamageEvent>(husk).Length,
"With an empty Charge pool the turret cannot fire (soft-fail).");
Assert.AreEqual(0u, em.GetComponentData<PlacedStructure>(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<DamageEvent>(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<DamageEvent>(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.");
}
}
} }
} }