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,71 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests the SaveData v2 structure-persistence schema (M7): a player-built structure set + the flat machine-I/O
|
||||
/// table round-trip through JsonUtility with fields intact; the REMAINING-ticks cooldown survives an epoch reset
|
||||
/// (RemainingTicks/RestoreNextTick); and a save JSON lacking the v2 arrays deserializes without throwing (degrades
|
||||
/// to no structures). The deeper instantiate-from-catalog restore is covered by the Play-mode validation pass.
|
||||
/// </summary>
|
||||
public class AutomationSaveRoundTripTests
|
||||
{
|
||||
[Test]
|
||||
public void StructuresAndIo_RoundTrip_PreservesFields()
|
||||
{
|
||||
var data = new SaveData
|
||||
{
|
||||
GoalCharge = 3,
|
||||
GoalTarget = 10,
|
||||
Structures = new[]
|
||||
{
|
||||
new StructureSave { Type = StructureType.Harvester, CellX = 4, CellZ = -2, RemainingTicks = 12 },
|
||||
new StructureSave { Type = StructureType.Conveyor, CellX = 5, CellZ = -2, Direction = 2, RemainingTicks = 3, ConveyorResId = ResourceId.Ore, ConveyorCount = 1 },
|
||||
new StructureSave { Type = StructureType.Fabricator, CellX = 6, CellZ = -2, RemainingTicks = 40 },
|
||||
},
|
||||
StructureIo = new[]
|
||||
{
|
||||
new StructureIoRow { StructureIndex = 0, Slot = 1, ResourceId = ResourceId.Ore, Count = 7 },
|
||||
new StructureIoRow { StructureIndex = 2, Slot = 0, ResourceId = ResourceId.Ore, Count = 5 },
|
||||
},
|
||||
};
|
||||
|
||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
||||
|
||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version);
|
||||
Assert.AreEqual(3, back.Structures.Length);
|
||||
Assert.AreEqual(StructureType.Conveyor, back.Structures[1].Type);
|
||||
Assert.AreEqual(2, back.Structures[1].Direction);
|
||||
Assert.AreEqual(ResourceId.Ore, back.Structures[1].ConveyorResId);
|
||||
Assert.AreEqual(1, back.Structures[1].ConveyorCount);
|
||||
Assert.AreEqual(12u, back.Structures[0].RemainingTicks);
|
||||
Assert.AreEqual(2, back.StructureIo.Length);
|
||||
Assert.AreEqual(2, back.StructureIo[1].StructureIndex);
|
||||
Assert.AreEqual(0, back.StructureIo[1].Slot);
|
||||
Assert.AreEqual(5, back.StructureIo[1].Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemainingTicks_RestoreNextTick_PreservesCooldownGap_AcrossEpochs()
|
||||
{
|
||||
uint saveNow = 5000u, savedNext = 5037u;
|
||||
uint remaining = ProductionMath.RemainingTicks(savedNext, saveNow);
|
||||
Assert.AreEqual(37u, remaining);
|
||||
|
||||
uint restoreNow = 11u;
|
||||
uint restoredNext = ProductionMath.RestoreNextTick(restoreNow, remaining);
|
||||
Assert.AreEqual(48u, restoredNext);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_Lacking_V2_Arrays_DeserializesWithoutThrowing()
|
||||
{
|
||||
SaveData back = null;
|
||||
Assert.DoesNotThrow(() => back = JsonUtility.FromJson<SaveData>("{\"Version\":2,\"GoalCharge\":1,\"GoalTarget\":10}"));
|
||||
Assert.IsNotNull(back);
|
||||
Assert.IsTrue(back.Structures == null || back.Structures.Length == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad495310b2721f147925ae48deb41bb4
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, world-free tests for <see cref="ConveyorMath"/> — the DETERMINISTIC, ORDER-INDEPENDENT belt resolver
|
||||
/// that the server <c>ConveyorTransportSystem</c> applies. Drives the array-based <c>ResolveMoves</c> directly
|
||||
/// (no ECS world) so determinism is provable: a 4-cell line advances exactly one cell/tick, a Y-junction lets a
|
||||
/// single deterministic winner (lowest CellKey source) into a shared destination while the loser STALLS with no
|
||||
/// loss, a blocked (already-occupied) destination stalls its source, and the END-STATE is IDENTICAL under two
|
||||
/// shuffled input orders. Sink (machine-input) cells always accept. <c>DirOffset</c>/<c>CellKey</c> are pinned too.
|
||||
/// </summary>
|
||||
public class ConveyorMathTests
|
||||
{
|
||||
const byte DirPosX = 0;
|
||||
const byte DirNegX = 1;
|
||||
const byte DirPosZ = 2;
|
||||
const byte DirNegZ = 3;
|
||||
|
||||
[Test]
|
||||
public void DirOffset_Maps_All_Four_Directions()
|
||||
{
|
||||
Assert.AreEqual(new int2(1, 0), ConveyorMath.DirOffset(DirPosX), "0 = +X");
|
||||
Assert.AreEqual(new int2(-1, 0), ConveyorMath.DirOffset(DirNegX), "1 = -X");
|
||||
Assert.AreEqual(new int2(0, 1), ConveyorMath.DirOffset(DirPosZ), "2 = +Z");
|
||||
Assert.AreEqual(new int2(0, -1), ConveyorMath.DirOffset(DirNegZ), "3 = -Z");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CellKey_Is_Stable_And_Order_Defining()
|
||||
{
|
||||
// The key must be a stable total order used to break move ties deterministically.
|
||||
long a = ConveyorMath.CellKey(new int2(0, 0));
|
||||
long b = ConveyorMath.CellKey(new int2(1, 0));
|
||||
long c = ConveyorMath.CellKey(new int2(0, 1));
|
||||
Assert.AreEqual(a, ConveyorMath.CellKey(new int2(0, 0)), "CellKey is deterministic for a given cell.");
|
||||
Assert.AreNotEqual(a, b);
|
||||
Assert.AreNotEqual(a, c);
|
||||
Assert.AreNotEqual(b, c, "Distinct cells map to distinct keys (no collision in-range).");
|
||||
}
|
||||
|
||||
// ---- ResolveMoves harness ------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One conveyor cell in a scenario: its grid cell, belt direction, and the item it currently holds.</summary>
|
||||
struct Belt
|
||||
{
|
||||
public int2 Cell;
|
||||
public byte Dir;
|
||||
public int Res; // 0 = empty
|
||||
public int Cnt; // 0 = empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives <see cref="ConveyorMath.ResolveMoves"/> over a set of belts (+ optional sink cells), returning the
|
||||
/// resolved (srcIndex -> destCell) move list as a dictionary keyed by source index for easy assertion.
|
||||
/// All native containers are Temp + disposed before returning.
|
||||
/// </summary>
|
||||
static Dictionary<int, int2> Resolve(Belt[] belts, int2[] sinks)
|
||||
{
|
||||
int n = belts.Length;
|
||||
var srcCells = new NativeArray<int2>(n, Allocator.Temp);
|
||||
var dirs = new NativeArray<byte>(n, Allocator.Temp);
|
||||
var itemRes = new NativeArray<int>(n, Allocator.Temp);
|
||||
var itemCnt = new NativeArray<int>(n, Allocator.Temp);
|
||||
var cellToIndex = new NativeHashMap<int2, int>(n, Allocator.Temp);
|
||||
var sinkCells = new NativeHashSet<int2>(math.max(1, sinks?.Length ?? 0), Allocator.Temp);
|
||||
var outMoveDst = new NativeArray<int2>(n, Allocator.Temp);
|
||||
var outMoveSrcIdx = new NativeArray<int>(n, Allocator.Temp);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
srcCells[i] = belts[i].Cell;
|
||||
dirs[i] = belts[i].Dir;
|
||||
itemRes[i] = belts[i].Res;
|
||||
itemCnt[i] = belts[i].Cnt;
|
||||
cellToIndex[belts[i].Cell] = i;
|
||||
}
|
||||
if (sinks != null)
|
||||
foreach (var s in sinks)
|
||||
sinkCells.Add(s);
|
||||
|
||||
ConveyorMath.ResolveMoves(srcCells, dirs, itemRes, itemCnt, cellToIndex, sinkCells,
|
||||
outMoveDst, outMoveSrcIdx, out int moveCount);
|
||||
|
||||
var result = new Dictionary<int, int2>();
|
||||
for (int m = 0; m < moveCount; m++)
|
||||
result[outMoveSrcIdx[m]] = outMoveDst[m];
|
||||
|
||||
srcCells.Dispose(); dirs.Dispose(); itemRes.Dispose(); itemCnt.Dispose();
|
||||
cellToIndex.Dispose(); sinkCells.Dispose(); outMoveDst.Dispose(); outMoveSrcIdx.Dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Line_Item_Advances_Exactly_One_Cell()
|
||||
{
|
||||
// 4-cell +X line at (0,0),(1,0),(2,0),(3,0). Only the head (index 0) holds an item; the cell ahead (1,0)
|
||||
// is EMPTY in the snapshot, so the item moves exactly one cell. No other belt holds an item -> no other move.
|
||||
var belts = new[]
|
||||
{
|
||||
new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = 0, Cnt = 0 },
|
||||
new Belt { Cell = new int2(2, 0), Dir = DirPosX, Res = 0, Cnt = 0 },
|
||||
new Belt { Cell = new int2(3, 0), Dir = DirPosX, Res = 0, Cnt = 0 },
|
||||
};
|
||||
|
||||
var moves = Resolve(belts, sinks: null);
|
||||
|
||||
Assert.AreEqual(1, moves.Count, "Exactly one item moves this tick.");
|
||||
Assert.IsTrue(moves.ContainsKey(0), "The head belt's item is the one that moves.");
|
||||
Assert.AreEqual(new int2(1, 0), moves[0], "The item advances exactly one cell along +X.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Full_Line_All_Advance_One_Cell_From_PreMove_Snapshot()
|
||||
{
|
||||
// Every belt in a 4-cell line holds an item, EXCEPT the head cell ahead of the line is open. Because
|
||||
// occupancy is read from the PRE-MOVE snapshot (double-buffered), each item shifts forward one cell —
|
||||
// the cell ahead was occupied in the snapshot for the tail belts, so ONLY the lead item (whose target is
|
||||
// empty in the snapshot) may advance. This pins the snapshot (not live) occupancy rule.
|
||||
var belts = new[]
|
||||
{
|
||||
new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (1,0) occupied in snapshot -> stall
|
||||
new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (2,0) occupied in snapshot -> stall
|
||||
new Belt { Cell = new int2(2, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (3,0) occupied in snapshot -> stall
|
||||
new Belt { Cell = new int2(3, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (4,0) not a belt, not a sink -> no move
|
||||
};
|
||||
|
||||
var moves = Resolve(belts, sinks: null);
|
||||
|
||||
Assert.AreEqual(0, moves.Count,
|
||||
"A fully-packed belt line with a dead end produces no moves (snapshot occupancy blocks every step).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sink_Cell_Always_Accepts_The_Head_Item()
|
||||
{
|
||||
// A 2-cell +X line whose head's target cell is a SINK (machine input). Sinks always accept (deposit),
|
||||
// even though they are not conveyor cells in cellToIndex.
|
||||
var belts = new[]
|
||||
{
|
||||
new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Biomass, Cnt = 2 },
|
||||
};
|
||||
var sinks = new[] { new int2(1, 0) };
|
||||
|
||||
var moves = Resolve(belts, sinks);
|
||||
|
||||
Assert.AreEqual(1, moves.Count, "The item moves into the adjacent sink.");
|
||||
Assert.AreEqual(new int2(1, 0), moves[0], "The destination is the sink cell.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Y_Junction_Deterministic_Winner_By_Lowest_CellKey_Loser_Stalls()
|
||||
{
|
||||
// Two source belts both feed the SAME destination (1,1):
|
||||
// A at (0,1) facing +X -> (1,1)
|
||||
// B at (1,0) facing +Z -> (1,1)
|
||||
// The destination is an EMPTY conveyor cell -> it accepts AT MOST ONE; the tie breaks to the lowest
|
||||
// CellKey source. The loser STALLS (no move) with no item loss.
|
||||
var dst = new int2(1, 1);
|
||||
var a = new int2(0, 1);
|
||||
var b = new int2(1, 0);
|
||||
|
||||
var belts = new[]
|
||||
{
|
||||
new Belt { Cell = a, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // index 0
|
||||
new Belt { Cell = b, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 }, // index 1
|
||||
new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 }, // index 2 (empty target)
|
||||
};
|
||||
|
||||
var moves = Resolve(belts, sinks: null);
|
||||
|
||||
// Exactly one of the two contenders wins; it targets dst. The winner is the lower-CellKey source.
|
||||
int winnerIdx = ConveyorMath.CellKey(a) < ConveyorMath.CellKey(b) ? 0 : 1;
|
||||
int loserIdx = winnerIdx == 0 ? 1 : 0;
|
||||
|
||||
Assert.AreEqual(1, moves.Count, "Only one item may enter the shared (empty) destination this tick.");
|
||||
Assert.IsTrue(moves.ContainsKey(winnerIdx), "The lowest-CellKey source wins the contended cell.");
|
||||
Assert.AreEqual(dst, moves[winnerIdx], "The winner moves into the shared destination.");
|
||||
Assert.IsFalse(moves.ContainsKey(loserIdx), "The loser stalls in place (no item loss).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Blocked_Cell_Stalls_The_Source()
|
||||
{
|
||||
// Head item at (0,0) facing +X; (1,0) is OCCUPIED in the snapshot (and its own item can't move because
|
||||
// (2,0) is not a belt/sink). The head item must STALL, not overwrite or destroy the blocker.
|
||||
var belts = new[]
|
||||
{
|
||||
new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = ResourceId.Biomass, Cnt = 1 }, // blocker; -> (2,0) dead end
|
||||
};
|
||||
|
||||
var moves = Resolve(belts, sinks: null);
|
||||
|
||||
Assert.IsFalse(moves.ContainsKey(0), "A source whose destination is occupied in the snapshot stalls (no loss).");
|
||||
Assert.IsFalse(moves.ContainsKey(1), "The blocker itself has a dead-end target and also stalls.");
|
||||
Assert.AreEqual(0, moves.Count, "Nothing moves when the only path is blocked.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EndState_Is_Identical_Under_Two_Shuffled_Input_Orders()
|
||||
{
|
||||
// A Y-junction plus a 4-cell line, fed in two DIFFERENT array orders. Because ResolveMoves iterates
|
||||
// sources SORTED by CellKey (not hashmap/array order), the resolved set of (srcCell -> destCell) moves
|
||||
// must be byte-for-byte identical regardless of input ordering. We compare keyed by CELL (order-stable),
|
||||
// not by array index (which differs between the two orderings).
|
||||
var dst = new int2(1, 1);
|
||||
var aCell = new int2(0, 1);
|
||||
var bCell = new int2(1, 0);
|
||||
|
||||
var orderOne = new[]
|
||||
{
|
||||
new Belt { Cell = aCell, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = bCell, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 },
|
||||
new Belt { Cell = new int2(5, 5), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // lone item -> (6,5) sink
|
||||
};
|
||||
// Same scenario, shuffled: reverse + interleave the array order.
|
||||
var orderTwo = new[]
|
||||
{
|
||||
new Belt { Cell = new int2(5, 5), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 },
|
||||
new Belt { Cell = bCell, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 },
|
||||
new Belt { Cell = aCell, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 },
|
||||
};
|
||||
var sinks = new[] { new int2(6, 5) };
|
||||
|
||||
var movesOne = Resolve(orderOne, sinks);
|
||||
var movesTwo = Resolve(orderTwo, sinks);
|
||||
|
||||
// Re-key both result sets by the SOURCE CELL (stable across orderings) -> destination cell.
|
||||
var byCellOne = ReKeyBySourceCell(orderOne, movesOne);
|
||||
var byCellTwo = ReKeyBySourceCell(orderTwo, movesTwo);
|
||||
|
||||
Assert.AreEqual(byCellOne.Count, byCellTwo.Count, "Both orderings resolve the same number of moves.");
|
||||
foreach (var kv in byCellOne)
|
||||
{
|
||||
Assert.IsTrue(byCellTwo.ContainsKey(kv.Key), $"Source cell {kv.Key} moved in order-1 but not order-2.");
|
||||
Assert.AreEqual(kv.Value, byCellTwo[kv.Key], $"Source cell {kv.Key} resolved to a different destination across orderings (NON-deterministic).");
|
||||
}
|
||||
}
|
||||
|
||||
static Dictionary<int2, int2> ReKeyBySourceCell(Belt[] belts, Dictionary<int, int2> movesBySrcIdx)
|
||||
{
|
||||
var byCell = new Dictionary<int2, int2>();
|
||||
foreach (var kv in movesBySrcIdx)
|
||||
byCell[belts[kv.Key].Cell] = kv.Value;
|
||||
return byCell;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7846ef025344bf428c68924d389d1b8
|
||||
@@ -0,0 +1,187 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5342d4ffd0bafeb418f5c2f9b3e3ee4e
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aeaf3925e6ddb6e4faeac39f32f98e5d
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5de181e5c7cfe54a803d176411f8944
|
||||
@@ -0,0 +1,134 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure tests for <see cref="ProductionMath"/> — the deterministic, world-free catch-up math the M7 automation
|
||||
/// systems (Harvester/Conveyor/Fabricator) share. Pins the SINGLE GATED catch-up path: a never-processed machine
|
||||
/// needs init (no production), a cooling machine yields 0 cycles, a due machine yields at least 1, a long-skipped
|
||||
/// machine is CAPPED at <c>maxCatchup</c> (no wall-clock mint), period=0 is guarded by <c>max(1,...)</c>, and the
|
||||
/// RemainingTicks/RestoreNextTick pair round-trips epoch-independently for save/restore.
|
||||
/// </summary>
|
||||
public class ProductionMathTests
|
||||
{
|
||||
[Test]
|
||||
public void NeedsInit_True_Only_For_Zero_LastProcessedTick()
|
||||
{
|
||||
Assert.IsTrue(ProductionMath.NeedsInit(0u), "A 0 LastProcessedTick is a never-processed (baked/uninit) machine.");
|
||||
Assert.IsFalse(ProductionMath.NeedsInit(1u), "Any non-zero tick has been initialized.");
|
||||
Assert.IsFalse(ProductionMath.NeedsInit(12345u));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_Cooling_Returns_Zero()
|
||||
{
|
||||
// NextTick is in the future relative to now -> still cooling, no production.
|
||||
var now = new NetworkTick(100u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600);
|
||||
Assert.AreEqual(0, cycles, "A machine whose NextTick is newer than now is cooling down (0 cycles).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_Exactly_One_Period_Elapsed_Returns_One()
|
||||
{
|
||||
// now == nextTick (not newer than) and one full period has elapsed since lastProcessed.
|
||||
var now = new NetworkTick(130u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600);
|
||||
Assert.AreEqual(1, cycles, "One elapsed period at the ready tick produces exactly one cycle.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_Multiple_Periods_Awards_Floor_Division()
|
||||
{
|
||||
// 100 ticks elapsed at period 30 -> floor(100/30) = 3.
|
||||
var now = new NetworkTick(200u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600);
|
||||
Assert.AreEqual(3, cycles, "Catch-up awards floor(elapsed/period) cycles.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_FarPast_Is_Capped_At_MaxCatchup()
|
||||
{
|
||||
// Huge elapsed gap must not mint unbounded production — clamp to maxCatchup.
|
||||
var now = new NetworkTick(1_000_000u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 31u, lastProcessedTick: 1u, period: 1, maxCatchup: 600);
|
||||
Assert.AreEqual(600, cycles, "A long-skipped machine is capped at maxCatchup (no wall-clock mint).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_Period_Zero_Is_Guarded_By_Max_One()
|
||||
{
|
||||
// period 0 must not divide-by-zero; max(1,period) means every elapsed tick is one cycle (then capped).
|
||||
var now = new NetworkTick(110u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 0, maxCatchup: 600);
|
||||
Assert.AreEqual(10, cycles, "period=0 is treated as 1 (floor(10/1) = 10), never a divide-by-zero.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_NonPositive_Elapsed_Returns_Zero()
|
||||
{
|
||||
// now == lastProcessed -> since == 0 -> 0 cycles (nothing due yet). NextTick=0 means "ready/inactive".
|
||||
var now = new NetworkTick(100u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 30, maxCatchup: 600);
|
||||
Assert.AreEqual(0, cycles, "Zero elapsed ticks since last process yields no cycles.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CyclesDue_Inactive_NextTick_Zero_Does_Not_Cool_Block()
|
||||
{
|
||||
// NextTick==0 is the "inactive/uninitialized" sentinel — it must NOT be read as a future cooling tick.
|
||||
// With a full period elapsed, the machine is due despite NextTick==0.
|
||||
var now = new NetworkTick(140u);
|
||||
int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 30, maxCatchup: 600);
|
||||
Assert.AreEqual(1, cycles, "NextTick==0 is the inactive sentinel, never a cooling gate.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemainingTicks_Zero_NextTick_Is_Inactive()
|
||||
{
|
||||
Assert.AreEqual(0u, ProductionMath.RemainingTicks(nextTick: 0u, nowTick: 100u),
|
||||
"An inactive (NextTick==0) machine has no remaining cooldown to persist.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemainingTicks_Future_NextTick_Returns_Gap()
|
||||
{
|
||||
Assert.AreEqual(25u, ProductionMath.RemainingTicks(nextTick: 125u, nowTick: 100u),
|
||||
"Remaining = nextTick - now when the next action is still in the future.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemainingTicks_Past_NextTick_Returns_Zero()
|
||||
{
|
||||
Assert.AreEqual(0u, ProductionMath.RemainingTicks(nextTick: 90u, nowTick: 100u),
|
||||
"A machine already past its NextTick has 0 remaining (it is due, not cooling).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemainingTicks_RestoreNextTick_RoundTrip_Is_EpochIndependent()
|
||||
{
|
||||
// Save at one epoch (saveNow), restore at an unrelated epoch (restoreNow): the COOLDOWN GAP is preserved
|
||||
// even though the absolute tick differs. This is why we persist remaining-ticks, not an absolute tick.
|
||||
uint saveNow = 1000u;
|
||||
uint savedNext = 1040u; // 40 ticks of cooldown remaining at save time
|
||||
uint remaining = ProductionMath.RemainingTicks(savedNext, saveNow);
|
||||
Assert.AreEqual(40u, remaining);
|
||||
|
||||
uint restoreNow = 7u; // a brand-new session, ticks start near 0
|
||||
uint restoredNext = ProductionMath.RestoreNextTick(restoreNow, remaining);
|
||||
Assert.AreEqual(47u, restoredNext, "Restore re-stamps now + remaining so the cooldown gap survives across sessions.");
|
||||
// And the gap measured from the restore epoch matches the original remaining.
|
||||
Assert.AreEqual(40u, ProductionMath.RemainingTicks(restoredNext, restoreNow));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RestoreNextTick_Coerces_Zero_Sum_Away_From_The_Inactive_Sentinel()
|
||||
{
|
||||
// now+remaining == 0 (both zero) must not collapse to the "inactive" sentinel; TickUtil.NonZero coerces to 1.
|
||||
uint restoredNext = ProductionMath.RestoreNextTick(nowTick: 0u, remaining: 0u);
|
||||
Assert.AreEqual(1u, restoredNext, "A 0 sum is coerced to 1 (the 0 = inactive sentinel is reserved).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c51d5b61b932d04c9273a3d674752a3
|
||||
@@ -4,6 +4,7 @@
|
||||
"references": [
|
||||
"ProjectM.Simulation",
|
||||
"ProjectM.Server",
|
||||
"ProjectM.Client",
|
||||
"Unity.Entities",
|
||||
"Unity.Transforms",
|
||||
"Unity.Collections",
|
||||
|
||||
Reference in New Issue
Block a user