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,8 @@
fileFormatVersion: 2
guid: d8478382df1bb34498b308c63531da1d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,123 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a
/// <see cref="PendingStructure"/>/<see cref="PendingStructureIo"/> carrier in the fresh ServerWorld BEFORE the
/// gameplay subscene streams; this system waits (RequireForUpdate) for the streamed <see cref="StructureCatalog"/>
/// + <see cref="BaseAnchor"/> + a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the
/// catalog prefab at the saved cell (preserving the baked Scale), re-stamp the rebased tick fields
/// (<see cref="ProductionMath.RestoreNextTick"/>; LastProcessed = now so within-session catch-up resumes from now,
/// never a wall-clock mint), re-tag RegionTag{Base} + RuntimePlacedTag, refill the in-flight conveyor item + the
/// machine I/O buffers, then DESTROY the carrier so it never runs again. The ledger/goal restore separately +
/// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct BaseRestoreSystem : ISystem
{
ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Conveyor> m_ConveyorLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<PendingStructure>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
m_TransformLookup.Update(ref state);
m_ConveyorLookup.Update(ref state);
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (pending, ioBuf, carrier) in
SystemAPI.Query<DynamicBuffer<PendingStructure>, DynamicBuffer<PendingStructureIo>>().WithEntityAccess())
{
for (int s = 0; s < pending.Length; s++)
{
var p = pending[s];
int entryIdx = -1;
for (int i = 0; i < catalog.Length; i++)
if (catalog[i].Type == p.Type) { entryIdx = i; break; }
if (entryIdx < 0 || catalog[entryIdx].Prefab == Entity.Null)
continue; // type not in the catalog (e.g. a save from a newer build) -> skip, don't crash
var prefab = catalog[entryIdx].Prefab;
var structure = ecb.Instantiate(prefab);
int2 cell = new int2(p.CellX, p.CellZ);
var xform = m_TransformLookup[prefab];
xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale (FromPosition would reset it)
ecb.SetComponent(structure, xform);
ecb.SetComponent(structure, new PlacedStructure
{
Type = p.Type,
Cell = cell,
NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks),
LastProcessedTick = TickUtil.NonZero(now),
});
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure);
if (p.Type == StructureType.Conveyor && m_ConveyorLookup.HasComponent(prefab))
{
var conv = m_ConveyorLookup[prefab];
conv.Direction = p.Direction;
ecb.SetComponent(structure, conv);
ecb.SetComponent(structure, new ConveyorItem { ResourceId = p.ConveyorResId, Count = p.ConveyorCount });
ecb.SetComponentEnabled<ConveyorItem>(structure, p.ConveyorCount > 0);
}
// Refill machine I/O buffers from the flat io table (only slots with saved rows -> the prefab has them).
bool inInit = false, outInit = false;
DynamicBuffer<MachineInput> inBuf = default;
DynamicBuffer<MachineOutput> outBuf = default;
for (int r = 0; r < ioBuf.Length; r++)
{
if (ioBuf[r].StructureIndex != s)
continue;
if (ioBuf[r].Slot == 0)
{
if (!inInit) { inBuf = ecb.SetBuffer<MachineInput>(structure); inInit = true; }
inBuf.Add(new MachineInput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count });
}
else
{
if (!outInit) { outBuf = ecb.SetBuffer<MachineOutput>(structure); outInit = true; }
outBuf.Add(new MachineOutput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count });
}
}
}
ecb.DestroyEntity(carrier);
}
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4003027ade5ccd5418e300d87e5c5e14
@@ -0,0 +1,276 @@
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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 002c28988137cb945b9ffaccbb6d645f
@@ -0,0 +1,99 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, deterministic fabricator production — the BACK of the M7 auto-gather chain
/// (Harvester → Conveyor → Fabricator). Each <see cref="Fabricator"/> consumes
/// <see cref="Fabricator.InAmount"/> of its (byte) input resource from its OWN server-only
/// <see cref="MachineInput"/> buffer (filled by an upstream conveyor) and, on a
/// <see cref="Fabricator.PeriodTicks"/> cadence, deposits <see cref="Fabricator.OutAmount"/> of its output
/// resource into the GLOBAL ledger — so a self-running base compounds its stockpile. Resolves the ledger via
/// <c>GetSingletonEntity&lt;ResourceLedger&gt;()</c> → <c>GetBuffer&lt;StorageEntry&gt;()</c> (NEVER
/// <c>GetSingleton&lt;StorageEntry&gt;</c> — a second StorageEntry buffer exists on the base container).
/// Mirrors <c>TurretFireSystem</c>'s now-extraction + cooldown idiom and <c>ResourceHarvestSystem</c>'s ledger
/// resolve; runs in the plain server <c>SimulationSystemGroup</c>
/// <c>[UpdateAfter(ConveyorTransportSystem)]</c> (which itself is after the harvester + the predicted group),
/// so a single tick can harvest → transport → fabricate in chain order. In-place buffer/ledger mutation
/// (not structural) → no ECB.
/// <para>
/// SINGLE GATED CATCH-UP PATH, INPUT-LIMITED (no mint-from-nothing): when due, the awarded
/// <see cref="ProductionMath.CyclesDue"/> cycles are further clamped to what the buffered input can afford
/// (<c>floor(TotalOf(input,InResourceId)/InAmount)</c>). The tick fields are re-stamped EVERY due period
/// regardless of <c>runs</c> (even a starved fabricator advances its baseline so it re-evaluates next period,
/// not on the next tick — preventing a busy retry storm). Offline catch-up is within-session tick math; never
/// wall-clock.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ConveyorTransportSystem))]
public partial struct FabricatorProductionSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<ResourceLedger>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Fabricator>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
foreach (var (ps, fab, input) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Fabricator>, DynamicBuffer<MachineInput>>())
{
int period = fab.ValueRO.PeriodTicks; // CyclesDue clamps to max(1, period)
// Never-processed (baked/just-placed) machine: initialise the catch-up baseline, produce nothing.
if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick))
{
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)System.Math.Max(1, period));
continue;
}
int cycles = ProductionMath.CyclesDue(
serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup);
if (cycles <= 0)
continue; // still cooling down / nothing due
byte inId = fab.ValueRO.InResourceId;
int inAmount = fab.ValueRO.InAmount;
// Input-limited: never produce more than the buffered input affords (no mint-from-nothing). A
// zero/negative recipe input amount is treated as unsatisfiable rather than dividing by zero.
int affordable = inAmount > 0
? MachineSlotMath.TotalOf(input, inId) / inAmount
: 0;
int runs = math.min(cycles, affordable);
if (runs > 0)
{
MachineSlotMath.Withdraw(input, inId, inAmount * runs);
StorageMath.Deposit(ledger, (ushort)fab.ValueRO.OutResourceId, fab.ValueRO.OutAmount * runs);
}
// Re-stamp every due period regardless of runs (starved fabricators re-evaluate next period, not
// every tick) so the catch-up baseline never silently rewinds.
uint p = (uint)System.Math.Max(1, period);
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + p);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d8dbf02b41c9a94ea57fd7ca00f266d
@@ -0,0 +1,76 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, deterministic harvester production — the FRONT of the M7 auto-gather chain
/// (Harvester → Conveyor → Fabricator). Each <see cref="Harvester"/> machine is a fixed-yield generator:
/// every <see cref="Harvester.PeriodTicks"/> server ticks it deposits <see cref="Harvester.Yield"/> of its
/// configured (byte) resource into its OWN server-only <see cref="MachineOutput"/> buffer (NOT the global
/// ledger — a conveyor pulls it onward, or it sits buffered). Mirrors <c>TurretFireSystem</c>'s exact
/// now-extraction (<c>NetworkTime.ServerTick.TickIndexForValidTick</c>) + <see cref="PlacedStructure.NextTick"/>
/// cooldown idiom, and runs in the plain server <c>SimulationSystemGroup</c>
/// <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> (the predicted group is OrderFirst → UpdateBefore is
/// ignored). Production mutates a DynamicBuffer in place (not a structural change) → no ECB needed.
/// <para>
/// SINGLE GATED CATCH-UP PATH (offline-quit safe, NO wall-clock minting): a never-processed machine
/// (LastProcessedTick==0) is initialised this tick and produces nothing; otherwise
/// <see cref="ProductionMath.CyclesDue"/> awards <c>floor((now-LastProcessedTick)/period)</c> cycles, clamped
/// to <see cref="Tuning.MaxProductionCatchup"/>, and the tick fields are re-stamped. All catch-up is
/// WITHIN-SESSION tick math; the stockpile is preserved across quit by the persistence layer, never re-minted
/// from a saved wall-clock.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct HarvesterProductionSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Harvester>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
foreach (var (ps, harvester, output) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Harvester>, DynamicBuffer<MachineOutput>>())
{
int period = harvester.ValueRO.PeriodTicks; // CyclesDue clamps to max(1, period)
// Never-processed (baked/just-placed) machine: initialise the catch-up baseline, produce nothing.
if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick))
{
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)System.Math.Max(1, period));
continue;
}
int cycles = ProductionMath.CyclesDue(
serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup);
if (cycles <= 0)
continue; // still cooling down / nothing due
// Fixed-yield generation into the machine's own output slot (byte id; ledger conversion happens
// only at the global-ledger boundary, which this machine never crosses directly).
MachineSlotMath.Deposit(output, harvester.ValueRO.ResourceId, harvester.ValueRO.Yield * cycles);
uint p = (uint)System.Math.Max(1, period);
ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now);
ps.ValueRW.NextTick = TickUtil.NonZero(now + p);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c484cb2331137134888f10eed7689140
@@ -23,11 +23,13 @@ namespace ProjectM.Server
public partial struct BuildPlaceSystem : ISystem
{
ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Conveyor> m_ConveyorLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<ResourceLedger>();
@@ -41,6 +43,7 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
m_TransformLookup.Update(ref state);
m_ConveyorLookup.Update(ref state);
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
@@ -88,9 +91,16 @@ namespace ProjectM.Server
Type = req.StructureType,
Cell = cell,
NextTick = 0u,
LastProcessedTick = TickUtil.NonZero(now),
LastProcessedTick = 0u, // 0 = uninitialized; the production systems set the baseline on first encounter (turret ignores it)
});
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure); // player-built -> persisted by SaveStructureScan
if (req.StructureType == StructureType.Conveyor && m_ConveyorLookup.HasComponent(entry.Prefab))
{
var conv = m_ConveyorLookup[entry.Prefab];
conv.Direction = req.Direction;
ecb.SetComponent(structure, conv);
}
}
}