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>
277 lines
13 KiB
C#
277 lines
13 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|