Files
Project-M/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs
kronic f3f65bccbf 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>
2026-06-06 15:05:15 -07:00

188 lines
9.6 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ConveyorTransportSystem"/> — the transport step
/// that PULLS resources off an adjacent upstream <see cref="MachineOutput"/> onto an empty belt, advances a held
/// <see cref="ConveyorItem"/> exactly one cell per tick along its <see cref="Conveyor.Direction"/>, and DEPOSITS
/// into a downstream machine's <see cref="MachineInput"/> sink. Determinism (Y-junction tie-break, stall-no-loss,
/// shuffle-invariance) is exhaustively proven world-free in <see cref="ConveyorMathTests"/>; these tests pin the
/// SYSTEM wiring: snapshot -> ResolveMoves -> apply (ConveyorItem enable/disable bit + sink deposit + machine pull),
/// plus the per-conveyor period gate (init on first touch, then move on the production cadence).
/// </summary>
public class ConveyorTransportSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ConveyorTransportSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
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 MakeConveyor(EntityManager em, int2 cell, byte dir, int periodTicks, byte itemRes, int itemCnt)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
em.AddComponentData(e, new PlacedStructure
{
Type = StructureType.Conveyor,
Cell = cell,
NextTick = 0u,
LastProcessedTick = 0u,
});
em.AddComponentData(e, new Conveyor { Direction = dir, PeriodTicks = periodTicks });
em.AddComponentData(e, new ConveyorItem { ResourceId = itemRes, Count = itemCnt });
em.SetComponentEnabled<ConveyorItem>(e, itemCnt > 0); // baked DISABLED unless carrying an item
return e;
}
static Entity MakeHarvesterOutput(EntityManager em, int2 cell, byte resourceId, int count)
{
// A minimal upstream producer: a PlacedStructure at a cell with a populated MachineOutput buffer.
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
em.AddComponentData(e, new PlacedStructure { Type = StructureType.Harvester, Cell = cell, NextTick = 0u, LastProcessedTick = 1u });
var output = em.AddBuffer<MachineOutput>(e);
if (count > 0)
output.Add(new MachineOutput { ResourceId = resourceId, Count = count });
return e;
}
static Entity MakeFabricatorInput(EntityManager em, int2 cell)
{
// A minimal downstream sink: a PlacedStructure at a cell with an (empty) MachineInput buffer.
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
em.AddComponentData(e, new PlacedStructure { Type = StructureType.Fabricator, Cell = cell, NextTick = 0u, LastProcessedTick = 1u });
em.AddBuffer<MachineInput>(e);
return e;
}
static int OutputOf(EntityManager em, Entity machine, byte resourceId)
{
var buf = em.GetBuffer<MachineOutput>(machine);
int total = 0;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ResourceId == resourceId) total += buf[i].Count;
return total;
}
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;
}
/// <summary>Ticks the system past its per-conveyor init gate so subsequent updates actually transport.</summary>
static void InitThenAdvance(World world, SimulationSystemGroup group, uint initTick, uint moveTick)
{
SetServerTick(world, initTick);
group.Update(); // first touch: init the conveyor period gate (no move)
SetServerTick(world, moveTick);
group.Update(); // a period later: the transport step runs
}
[Test]
public void Conveyor_Pulls_From_Adjacent_Upstream_MachineOutput_When_Empty()
{
var (world, group) = MakeWorld("ConveyorPull", serverTick: 100);
using (world)
{
var em = world.EntityManager;
// Harvester output at (0,0); an EMPTY belt at (1,0) facing +X. The belt should pull one unit from the
// upstream output (the harvester's cell (0,0) is belt.cell - DirOffset = (1,0)-(1,0) = (0,0)).
var harvester = MakeHarvesterOutput(em, new int2(0, 0), ResourceId.Ore, count: 4);
var belt = MakeConveyor(em, new int2(1, 0), dir: 0 /*+X*/, periodTicks: 30, itemRes: 0, itemCnt: 0);
InitThenAdvance(world, group, initTick: 100, moveTick: 130);
Assert.IsTrue(em.IsComponentEnabled<ConveyorItem>(belt), "An empty belt adjacent to an upstream output pulls an item (ConveyorItem enabled).");
var item = em.GetComponentData<ConveyorItem>(belt);
Assert.AreEqual(ResourceId.Ore, item.ResourceId, "The pulled item carries the upstream resource id.");
Assert.AreEqual(3, OutputOf(em, harvester, ResourceId.Ore), "Exactly one unit is pulled off the upstream output (4 -> 3).");
}
}
[Test]
public void Item_Advances_Exactly_One_Cell_Along_Direction()
{
var (world, group) = MakeWorld("ConveyorAdvance", serverTick: 100);
using (world)
{
var em = world.EntityManager;
// Two +X belts (0,0)->(1,0). The source carries an item; the destination belt is empty. After a tick
// the item should be on the destination belt and the source belt empty.
var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Biomass, itemCnt: 1);
var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0);
InitThenAdvance(world, group, initTick: 100, moveTick: 130);
Assert.IsFalse(em.IsComponentEnabled<ConveyorItem>(src), "The item leaves the source belt (now empty).");
Assert.IsTrue(em.IsComponentEnabled<ConveyorItem>(dst), "The item arrives on the next belt (exactly one cell along +X).");
Assert.AreEqual(ResourceId.Biomass, em.GetComponentData<ConveyorItem>(dst).ResourceId, "The carried resource id is preserved across the move.");
}
}
[Test]
public void Item_Deposits_Into_Downstream_Machine_Input_Sink()
{
var (world, group) = MakeWorld("ConveyorSink", serverTick: 100);
using (world)
{
var em = world.EntityManager;
// A +X belt at (0,0) carrying an item; a fabricator input at (1,0) is the sink. The item should be
// deposited into the machine's MachineInput and removed from the belt.
var belt = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1);
var fab = MakeFabricatorInput(em, new int2(1, 0));
InitThenAdvance(world, group, initTick: 100, moveTick: 130);
Assert.AreEqual(1, InputOf(em, fab, ResourceId.Ore), "The item is deposited into the downstream machine's input.");
Assert.IsFalse(em.IsComponentEnabled<ConveyorItem>(belt), "The belt is empty after handing its item to the sink.");
}
}
[Test]
public void Does_Not_Transport_While_Cooling_Down()
{
var (world, group) = MakeWorld("ConveyorCooling", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1);
var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0);
SetServerTick(world, 100);
group.Update(); // init (NextTick -> 130)
SetServerTick(world, 115);
group.Update(); // 115 < 130 -> belt is still cooling, no move
Assert.IsTrue(em.IsComponentEnabled<ConveyorItem>(src), "The item stays put while the belt is cooling down.");
Assert.IsFalse(em.IsComponentEnabled<ConveyorItem>(dst), "Nothing arrives before the belt's period elapses.");
}
}
}
}