using NUnit.Framework; using ProjectM.Simulation; using Unity.NetCode; namespace ProjectM.Tests { /// /// Pure tests for — 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 maxCatchup (no wall-clock mint), period=0 is guarded by max(1,...), and the /// RemainingTicks/RestoreNextTick pair round-trips epoch-independently for save/restore. /// 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)."); } } }