f3f65bccbf
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>
188 lines
9.6 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|