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:
2026-06-06 15:05:15 -07:00
parent 31c4ab16d6
commit f3f65bccbf
49 changed files with 2599 additions and 1 deletions
@@ -0,0 +1,97 @@
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic, ORDER-INDEPENDENT conveyor move resolver (the server <c>ConveyorTransportSystem</c>
/// applies the result). Determinism: sources are processed sorted by <see cref="CellKey"/> (NEVER hashmap
/// order); a destination belt cell accepts AT MOST ONE item and only if it was EMPTY in the pre-move snapshot
/// (double-buffering -> exactly one cell/tick); ties break to the lowest-CellKey source and losers STALL with no
/// loss; machine-input SINK cells always accept (a merge). World-free so it is exhaustively unit-tested.
/// </summary>
public static class ConveyorMath
{
/// <summary>Cardinal grid step for a belt direction byte (0=+X,1=-X,2=+Z,3=-Z).</summary>
public static int2 DirOffset(byte dir)
{
switch (dir)
{
case 1: return new int2(-1, 0);
case 2: return new int2(0, 1);
case 3: return new int2(0, -1);
default: return new int2(1, 0); // 0 = +X
}
}
/// <summary>A stable, collision-free total order over grid cells (the deterministic tie-break key).</summary>
public static long CellKey(int2 cell) => ((long)cell.x << 32) | (uint)cell.y;
/// <summary>
/// Resolve, for each belt holding an item, whether it advances one cell toward its direction this tick.
/// Inputs are read-only snapshots; outputs are the accepted moves (<paramref name="outMoveSrcIdx"/> ->
/// <paramref name="outMoveDst"/>), length <paramref name="moveCount"/>. A move is accepted when the
/// destination is a SINK cell (always, a merge) or an EMPTY, unclaimed belt cell. Sources are iterated in
/// CellKey order so the result is identical regardless of input array order. Scratch is Temp + disposed.
/// </summary>
public static void ResolveMoves(
NativeArray<int2> srcCells, NativeArray<byte> dirs,
NativeArray<int> itemRes, NativeArray<int> itemCnt,
NativeHashMap<int2, int> cellToIndex, NativeHashSet<int2> sinkCells,
NativeArray<int2> outMoveDst, NativeArray<int> outMoveSrcIdx, out int moveCount)
{
int n = srcCells.Length;
moveCount = 0;
// Stable iteration order = sources sorted by CellKey (insertion sort; n is small).
var order = new NativeArray<int>(n, Allocator.Temp);
for (int i = 0; i < n; i++) order[i] = i;
for (int i = 1; i < n; i++)
{
int cur = order[i];
long curKey = CellKey(srcCells[cur]);
int j = i - 1;
while (j >= 0 && CellKey(srcCells[order[j]]) > curKey)
{
order[j + 1] = order[j];
j--;
}
order[j + 1] = cur;
}
var claimed = new NativeHashSet<int2>(n, Allocator.Temp);
for (int oi = 0; oi < n; oi++)
{
int i = order[oi];
if (itemCnt[i] <= 0) continue; // nothing to move
int2 dst = srcCells[i] + DirOffset(dirs[i]);
bool accept = false;
bool isSink = sinkCells.Contains(dst);
if (isSink)
{
accept = true; // sinks merge -> unlimited acceptors, never claimed
}
else if (cellToIndex.TryGetValue(dst, out int dstIdx))
{
// dst is a belt cell: accept only if EMPTY in the snapshot AND not already claimed this tick.
if (itemCnt[dstIdx] == 0 && !claimed.Contains(dst))
accept = true;
}
// else: dst is neither a belt nor a sink -> dead end -> stall.
if (accept)
{
if (!isSink) claimed.Add(dst);
outMoveDst[moveCount] = dst;
outMoveSrcIdx[moveCount] = i;
moveCount++;
}
}
order.Dispose();
claimed.Dispose();
}
}
}