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
@@ -11,15 +11,21 @@ namespace ProjectM.Tests
{
/// <summary>
/// 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
/// + 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
/// <c>PlacedStructure.NextTick</c> cooldown, and appends a <c>DamageEvent{SourceNetworkId=-1}</c>. These tests
/// pin range filtering, region gating, and the wrap-safe cooldown.
/// A bare world is seeded with a <c>NetworkTime</c> singleton, a <see cref="ResourceLedger"/> 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 <c>PlacedStructure.NextTick</c> cooldown — but ONLY when
/// 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>
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<SimulationSystemGroup>();
@@ -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<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)
@@ -59,10 +70,18 @@ namespace ProjectM.Tests
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]
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<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.");
}
}
}
}