M7 Automation: deterministic Harvester to Conveyor to Fabricator chains
Server-only production chains (never predicted): components + server systems + pure byte-only math (ProductionMath/ConveyorMath/MachineSlotMath), authoring + 3 machine prefabs wired into the Gameplay subscene, StructureCatalog rows, BuildPlace Direction/RuntimePlacedTag, Tuning, and 35 EditMode tests (catch-up gating, conveyor shuffle-invariance, SaveData v2 round-trip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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 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<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.
|
||||
/// </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)
|
||||
{
|
||||
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,
|
||||
});
|
||||
var input = em.AddBuffer<MachineInput>(e);
|
||||
if (seedInput > 0)
|
||||
input.Add(new MachineInput { ResourceId = inId, Count = seedInput });
|
||||
em.AddBuffer<MachineOutput>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user