Files
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

277 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, deterministic conveyor transport — the MIDDLE of the M7 auto-gather chain
/// (Harvester → Conveyor → Fabricator). Unlike the per-machine catch-up production systems, a conveyor is a
/// single TRANSPORT STEP: each period-due, empty <see cref="Conveyor"/> first PULLS one item off an adjacent
/// upstream <see cref="MachineOutput"/> (the cell at <c>myCell DirOffset(dir)</c> — i.e. the machine feeding
/// INTO this belt) onto its own <see cref="ConveyorItem"/>; then every loaded conveyor advances its item
/// EXACTLY one cell toward <c>myCell + DirOffset(dir)</c>. The move resolution is delegated to the pure,
/// unit-tested <see cref="ConveyorMath.ResolveMoves"/> so determinism is provable WITHOUT a world: sources are
/// processed sorted by <see cref="ConveyorMath.CellKey"/> (NOT hashmap order), occupancy is read from a
/// pre-move double-buffer snapshot, a destination conveyor cell accepts at most one item (only if it was empty
/// in the snapshot; ties → lowest CellKey wins, losers stall with no silent loss), and machine-input sink
/// cells always accept (deposit). Sinks are a separate set so an item leaving the belt into a fabricator's
/// <see cref="MachineInput"/> never collides with belt occupancy.
/// <para>
/// Mirrors <c>TurretFireSystem</c>'s now-extraction (<c>NetworkTime.ServerTick.TickIndexForValidTick</c>) +
/// <see cref="PlacedStructure.NextTick"/> cooldown idiom (each conveyor is period-gated the same way), and
/// <c>ResourceHarvestSystem</c>'s Temp-collection foreach idiom. Runs in the plain server
/// <c>SimulationSystemGroup</c> <c>[UpdateAfter(HarvesterProductionSystem)]</c> (after harvesters deposit, so
/// fresh output is pull-eligible this tick; before the fabricator consumes). All buffer/enableable mutation is
/// in place (toggling an enableable bit is NOT a structural change) → no ECB.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(HarvesterProductionSystem))]
public partial struct ConveyorTransportSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Conveyor>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// ---- Snapshot every conveyor once (entity, cell, direction, item, period-due) ----
var convEntity = new NativeList<Entity>(Allocator.Temp);
var convCell = new NativeList<int2>(Allocator.Temp);
var convDir = new NativeList<byte>(Allocator.Temp);
var convItemRes = new NativeList<int>(Allocator.Temp); // 0 = empty
var convItemCnt = new NativeList<int>(Allocator.Temp); // 0 = empty
var convDue = new NativeList<bool>(Allocator.Temp); // period-gate satisfied this tick
foreach (var (ps, conveyor, e) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Conveyor>>().WithEntityAccess())
{
int period = math.max(1, conveyor.ValueRO.PeriodTicks);
// Period-gate each conveyor through NextTick exactly like the production systems. A never-processed
// belt initialises its baseline this tick and is NOT due (mirrors NeedsInit on the machines).
bool due;
if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick))
{
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)period);
due = false;
}
else
{
int cycles = ProductionMath.CyclesDue(
serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup);
due = cycles > 0;
if (due)
{
// A belt moves at most one cell per period; collapse any catch-up to a single step but keep
// the baseline advancing so it re-evaluates next period.
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)period);
}
}
int res = 0, cnt = 0;
if (SystemAPI.IsComponentEnabled<ConveyorItem>(e))
{
var item = SystemAPI.GetComponent<ConveyorItem>(e);
if (item.Count > 0)
{
res = item.ResourceId;
cnt = item.Count;
}
}
convEntity.Add(e);
convCell.Add(ps.ValueRO.Cell);
convDir.Add(conveyor.ValueRO.Direction);
convItemRes.Add(res);
convItemCnt.Add(cnt);
convDue.Add(due);
}
int n = convEntity.Length;
// Cell → conveyor snapshot index (belt occupancy map for ResolveMoves + the pull lookup).
var cellToIndex = new NativeHashMap<int2, int>(n, Allocator.Temp);
for (int i = 0; i < n; i++)
cellToIndex.TryAdd(convCell[i], i); // duplicate cells can't occur (one structure per cell)
// Sink cells = cells hosting a machine-input buffer (fabricators); these always accept a deposit. Map
// each sink cell to its owning entity so an arriving item can be deposited into its MachineInput.
var sinkCells = new NativeHashSet<int2>(8, Allocator.Temp);
var sinkCellToEntity = new NativeHashMap<int2, Entity>(8, Allocator.Temp);
foreach (var (ps, _, e) in
SystemAPI.Query<RefRO<PlacedStructure>, DynamicBuffer<MachineInput>>().WithEntityAccess())
{
sinkCells.Add(ps.ValueRO.Cell);
sinkCellToEntity.TryAdd(ps.ValueRO.Cell, e);
}
// Source cells = cells hosting a machine-OUTPUT buffer (harvesters/fabricators) a belt can pull from.
// Built once so the pull phase is a single hash lookup per belt (no nested per-belt query).
var outputCellToEntity = new NativeHashMap<int2, Entity>(8, Allocator.Temp);
foreach (var (ps, _, e) in
SystemAPI.Query<RefRO<PlacedStructure>, DynamicBuffer<MachineOutput>>().WithEntityAccess())
{
outputCellToEntity.TryAdd(ps.ValueRO.Cell, e);
}
// ---- PULL: each empty, due belt draws one item off an adjacent UPSTREAM MachineOutput ----
// Upstream cell = myCell DirOffset(dir): the machine feeding INTO this belt sits there. We pull a
// single unit so a harvester's buffered output flows onto the belt one item per period.
for (int i = 0; i < n; i++)
{
if (!convDue[i] || convItemCnt[i] > 0)
continue;
int2 srcCell = convCell[i] - ConveyorMath.DirOffset(convDir[i]);
// The feeder must be a machine with a MachineOutput buffer (harvester or fabricator), NOT another
// conveyor (belts hand off in the move phase, not via pull). Single hash lookup on the prebuilt map.
if (!outputCellToEntity.TryGetValue(srcCell, out var feeder))
continue;
var output = SystemAPI.GetBuffer<MachineOutput>(feeder);
// Pull the first available resource row off the feeder (deterministic: first non-empty row order).
byte pulledId = 0;
for (int r = 0; r < output.Length; r++)
{
if (output[r].ResourceId != 0 && output[r].Count > 0)
{
pulledId = output[r].ResourceId;
break;
}
}
if (pulledId == 0)
continue;
int taken = MachineSlotMath.Withdraw(output, pulledId, 1);
if (taken <= 0)
continue;
// Load the belt in the snapshot so it participates in THIS tick's move resolution.
convItemRes[i] = pulledId;
convItemCnt[i] = taken;
}
// ---- MOVE: resolve all belt advances from the pre-move (post-pull) snapshot, then apply ----
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);
for (int i = 0; i < n; i++)
{
srcCells[i] = convCell[i];
dirs[i] = convDir[i];
// Pass the FULL post-pull occupancy (due AND non-due) so the resolver blocks a due belt from moving
// INTO an occupied non-due cell. Non-due belts must still not ADVANCE themselves — that is enforced
// after resolution by skipping any returned move whose source belt is not due this tick.
itemRes[i] = convItemRes[i];
itemCnt[i] = convItemCnt[i];
}
var outMoveDst = new NativeArray<int2>(n, Allocator.Temp);
var outMoveSrcIdx = new NativeArray<int>(n, Allocator.Temp);
ConveyorMath.ResolveMoves(
srcCells, dirs, itemRes, itemCnt,
cellToIndex, sinkCells,
outMoveDst, outMoveSrcIdx, out int moveCount);
// Track which belts END this tick holding an item so we can settle enableable bits exactly once.
var endRes = new NativeArray<int>(n, Allocator.Temp);
var endCnt = new NativeArray<int>(n, Allocator.Temp);
for (int i = 0; i < n; i++)
{
// Default: every snapshot item stays put (stalls / non-due / no valid move). Moves below override.
endRes[i] = convItemRes[i];
endCnt[i] = convItemCnt[i];
}
for (int m = 0; m < moveCount; m++)
{
int srcIdx = outMoveSrcIdx[m];
// A non-due belt contributes its occupancy to the snapshot (so due belts can't overrun it) but must
// NOT advance its own item — skip its move and leave its item parked (the endRes/endCnt defaults).
if (!convDue[srcIdx])
continue;
int2 dst = outMoveDst[m];
int movRes = convItemRes[srcIdx];
int movCnt = convItemCnt[srcIdx];
// The source belt empties (its item left this cell).
endRes[srcIdx] = 0;
endCnt[srcIdx] = 0;
if (sinkCells.Contains(dst))
{
// Item leaves the belt network into a machine input slot.
if (sinkCellToEntity.TryGetValue(dst, out var sinkEntity))
{
var input = SystemAPI.GetBuffer<MachineInput>(sinkEntity);
MachineSlotMath.Deposit(input, (byte)movRes, movCnt);
}
}
else if (cellToIndex.TryGetValue(dst, out int dstIdx))
{
// Item advances onto the next belt cell (resolver guaranteed it was empty in the snapshot).
endRes[dstIdx] = movRes;
endCnt[dstIdx] = movCnt;
}
}
// ---- Settle each conveyor's ConveyorItem to its end-of-tick state (single write per belt) ----
for (int i = 0; i < n; i++)
{
var e = convEntity[i];
if (endCnt[i] > 0)
{
SystemAPI.SetComponent(e, new ConveyorItem { ResourceId = (byte)endRes[i], Count = endCnt[i] });
SystemAPI.SetComponentEnabled<ConveyorItem>(e, true);
}
else
{
SystemAPI.SetComponent(e, new ConveyorItem { ResourceId = 0, Count = 0 });
SystemAPI.SetComponentEnabled<ConveyorItem>(e, false);
}
}
convEntity.Dispose();
convCell.Dispose();
convDir.Dispose();
convItemRes.Dispose();
convItemCnt.Dispose();
convDue.Dispose();
cellToIndex.Dispose();
sinkCells.Dispose();
sinkCellToEntity.Dispose();
outputCellToEntity.Dispose();
srcCells.Dispose();
dirs.Dispose();
itemRes.Dispose();
itemCnt.Dispose();
outMoveDst.Dispose();
outMoveSrcIdx.Dispose();
endRes.Dispose();
endCnt.Dispose();
}
}
}