Files
Project-M/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs
kronic 44da26cdf6 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>
2026-06-12 19:14:58 -07:00

261 lines
13 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
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 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
{
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<FabricatorProductionSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
em.AddBuffer<StorageEntry>(ledger);
return (world, group, ledger);
}
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
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, byte fromLedger = 0)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
em.AddComponentData(e, new PlacedStructure
{
Type = StructureType.Fabricator,
NextTick = 0u,
LastProcessedTick = 0u,
});
em.AddComponentData(e, new Fabricator
{
InResourceId = inId,
InAmount = inAmt,
OutResourceId = outId,
OutAmount = outAmt,
PeriodTicks = periodTicks,
InputFromLedger = fromLedger,
});
var input = em.AddBuffer<MachineInput>(e);
if (seedInput > 0)
input.Add(new MachineInput { ResourceId = inId, Count = seedInput });
em.AddBuffer<MachineOutput>(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)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == itemId) return buf[i].Count;
return 0;
}
static int InputOf(EntityManager em, Entity machine, byte resourceId)
{
var buf = em.GetBuffer<MachineInput>(machine);
int total = 0;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ResourceId == resourceId)
total += buf[i].Count;
return total;
}
[Test]
public void First_Update_Initializes_Without_Producing()
{
var (world, group, ledger) = MakeWorld("FabInit", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 10);
group.Update();
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "First touch only initializes (no production).");
Assert.AreEqual(10, InputOf(em, f, ResourceId.Ore), "No input is consumed during init.");
var ps = em.GetComponentData<PlacedStructure>(f);
Assert.AreNotEqual(0u, ps.LastProcessedTick);
Assert.AreNotEqual(0u, ps.NextTick);
}
}
[Test]
public void Produces_One_Run_Per_Period_When_Input_Is_Available()
{
var (world, group, ledger) = MakeWorld("FabRun", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 10);
group.Update(); // init (NextTick -> 130)
SetServerTick(world, 130);
group.Update(); // one period elapsed, input affords it -> 1 run
Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether), "One run deposits OutAmount into the ledger.");
Assert.AreEqual(8, InputOf(em, f, ResourceId.Ore), "One run consumes InAmount from the input buffer (10 - 2).");
}
}
[Test]
public void Is_Input_Limited_No_Mint_From_Empty_Slot()
{
var (world, group, ledger) = MakeWorld("FabStarved", serverTick: 100);
using (world)
{
var em = world.EntityManager;
// Empty input slot: even with periods elapsed, affordable == 0 -> runs == 0 -> nothing minted.
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 0);
group.Update(); // init
SetServerTick(world, 250); // plenty of periods elapsed
group.Update();
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether),
"A starved fabricator mints nothing — production is strictly input-limited.");
Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "No phantom input appears.");
}
}
[Test]
public void Runs_Are_Clamped_To_Affordable_Input()
{
var (world, group, ledger) = MakeWorld("FabAfford", serverTick: 100);
using (world)
{
var em = world.EntityManager;
// 5 periods become due (150 ticks / 30), but only 3 runs are affordable (7 input / 2 per run = 3).
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 7);
group.Update(); // init at 100
SetServerTick(world, 250); // floor(150/30) = 5 cycles due
group.Update();
Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether),
"runs = min(cyclesDue=5, affordable=3) = 3 — output is clamped to available input.");
Assert.AreEqual(1, InputOf(em, f, ResourceId.Ore), "3 runs consume 6 of 7 input, leaving 1.");
}
}
[Test]
public void CatchUp_Awards_Exact_Multiple_When_Input_Allows()
{
var (world, group, ledger) = MakeWorld("FabCatchUp", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 1, outId: ResourceId.Biomass, outAmt: 2, periodTicks: 30, seedInput: 1000);
group.Update(); // init at 100
SetServerTick(world, 250); // floor(150/30) = 5 cycles, all affordable
group.Update();
Assert.AreEqual(10, LedgerCount(em, ledger, ResourceId.Biomass), "5 runs * OutAmount(2) = 10 deposited.");
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.");
}
}
}
}