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,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).");
}
}
}