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,153 @@
|
||||
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="HarvesterProductionSystem"/> — the fixed-yield
|
||||
/// generator that deposits <c>Yield * cycles</c> of its resource into its OWN <see cref="MachineOutput"/> buffer
|
||||
/// on a deterministic period. Pins the SINGLE GATED catch-up path from the M7 contract: a never-processed machine
|
||||
/// (LastProcessedTick==0) only INITIALIZES on its first touch (no production), then produces exactly one yield per
|
||||
/// elapsed period, catch-up after skipped ticks awards the exact (capped) amount, and a cooling machine produces
|
||||
/// nothing. Output stays in the machine's local buffer (server-only, no GhostField) — the conveyor pulls it later.
|
||||
/// </summary>
|
||||
public class HarvesterProductionSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<HarvesterProductionSystem>());
|
||||
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 MakeHarvester(EntityManager em, byte resourceId, int yield, int periodTicks)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||
em.AddComponentData(e, new PlacedStructure
|
||||
{
|
||||
Type = StructureType.Harvester,
|
||||
NextTick = 0u,
|
||||
LastProcessedTick = 0u, // never processed -> first update only initializes
|
||||
});
|
||||
em.AddComponentData(e, new Harvester { ResourceId = resourceId, Yield = yield, PeriodTicks = periodTicks });
|
||||
em.AddBuffer<MachineOutput>(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;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void First_Update_Initializes_Without_Producing()
|
||||
{
|
||||
var (world, group) = MakeWorld("HarvesterInit", serverTick: 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, OutputOf(em, h, ResourceId.Aether),
|
||||
"A never-processed machine only initializes on its first touch (no production).");
|
||||
var ps = em.GetComponentData<PlacedStructure>(h);
|
||||
Assert.AreNotEqual(0u, ps.LastProcessedTick, "Init stamps LastProcessedTick to 'now'.");
|
||||
Assert.AreNotEqual(0u, ps.NextTick, "Init stamps the next production tick (now + period).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Produces_One_Yield_After_Exactly_One_Period()
|
||||
{
|
||||
var (world, group) = MakeWorld("HarvesterOnePeriod", serverTick: 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30);
|
||||
|
||||
group.Update(); // tick 100: init (NextTick -> 130)
|
||||
SetServerTick(world, 130);
|
||||
group.Update(); // tick 130: one period elapsed -> +5
|
||||
|
||||
Assert.AreEqual(5, OutputOf(em, h, ResourceId.Aether), "One elapsed period yields exactly Yield.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Does_Not_Produce_While_Cooling_Down()
|
||||
{
|
||||
var (world, group) = MakeWorld("HarvesterCooling", serverTick: 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var h = MakeHarvester(em, ResourceId.Ore, yield: 5, periodTicks: 30);
|
||||
|
||||
group.Update(); // init (NextTick -> 130)
|
||||
SetServerTick(world, 115);
|
||||
group.Update(); // 115 < 130 -> still cooling
|
||||
|
||||
Assert.AreEqual(0, OutputOf(em, h, ResourceId.Ore), "No production before the period elapses.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatchUp_Awards_Exact_Multiple_For_Skipped_Periods()
|
||||
{
|
||||
var (world, group) = MakeWorld("HarvesterCatchUp", serverTick: 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var h = MakeHarvester(em, ResourceId.Biomass, yield: 5, periodTicks: 30);
|
||||
|
||||
group.Update(); // init at 100 (LastProcessedTick -> 100)
|
||||
SetServerTick(world, 250); // 150 ticks elapsed -> floor(150/30) = 5 cycles
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(25, OutputOf(em, h, ResourceId.Biomass),
|
||||
"Catch-up awards Yield * floor(elapsed/period) = 5 * 5 = 25 (skipped ticks are not lost).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatchUp_Is_Capped_At_MaxProductionCatchup()
|
||||
{
|
||||
var (world, group) = MakeWorld("HarvesterCap", serverTick: 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var h = MakeHarvester(em, ResourceId.Aether, yield: 1, periodTicks: 1);
|
||||
|
||||
group.Update(); // init at 100
|
||||
SetServerTick(world, 100u + 5_000_000u); // an absurd gap
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(Tuning.MaxProductionCatchup, OutputOf(em, h, ResourceId.Aether),
|
||||
"A long gap is capped at MaxProductionCatchup cycles (no unbounded mint).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user