From c47f8d9fa78966e3736e6573434697a494aec5c4 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 10:33:59 -0400 Subject: [PATCH] feat(rng-seam): HeadlessBattleMgr override + decoupling/parity tests (F2 resolved) Co-Authored-By: Claude Sonnet 4.6 --- SVSim.BattleEngine.Tests/RngSeamTests.cs | 45 ++++++++++++++++++++ SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs | 42 ++++++++++++++++++ SVSim.BattleEngine/Rng/RandomSourceBridge.cs | 2 +- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs diff --git a/SVSim.BattleEngine.Tests/RngSeamTests.cs b/SVSim.BattleEngine.Tests/RngSeamTests.cs index a3b3fe7..3fe5bd6 100644 --- a/SVSim.BattleEngine.Tests/RngSeamTests.cs +++ b/SVSim.BattleEngine.Tests/RngSeamTests.cs @@ -1,6 +1,8 @@ using System; using NUnit.Framework; using SVSim.BattleEngine.Rng; +using Wizard; +using Wizard.Battle; namespace SVSim.BattleEngine.Tests { @@ -51,5 +53,48 @@ namespace SVSim.BattleEngine.Tests Assert.That(src.NextSelf(99), Is.EqualTo(3)); Assert.That(() => src.NextSelf(99), Throws.InvalidOperationException, "should throw on self overrun"); } + + // The decoupling (F2): the override must roll REAL values even though IsForecast == true (which + // forces the un-overridden engine methods to return 0). A ScriptedRandomSource proves the value + // came from the injected source, not the engine's zeroing. + [Test] + public void Override_rolls_real_values_under_IsForecast() + { + HeadlessEngineEnv.EnsureInitialized(); + BattleManagerBase.IsForecast = true; // would zero the un-overridden engine RNG + + // 3 units; with RandomSourceBridge.Range(val, unit) = floor(val*unit): + // StableRandom(7) with 0.5 -> floor(3.5) = 3 + // StableRandomDouble() -> 0.25 + // StableRandomOnlySelf(10) -> scripted self pick 4 + var src = new ScriptedRandomSource(new[] { 0.5, 0.25 }, new[] { 4 }); + var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), src); + + Assert.That(mgr.StableRandom(7), Is.EqualTo(3), "StableRandom did not use the injected source"); + Assert.That(mgr.randomResult, Is.EqualTo(0.5), "StableRandom must set randomResult to the rolled unit"); + Assert.That(mgr.StableRandomDouble(), Is.EqualTo(0.25), "StableRandomDouble did not use the injected source"); + Assert.That(mgr.randomResult, Is.EqualTo(0.25), "StableRandomDouble must set randomResult"); + Assert.That(mgr.StableRandomOnlySelf(10), Is.EqualTo(4), "StableRandomOnlySelf did not use the injected source"); + } + + // Parity: with the DEFAULT (seeded) source, HeadlessBattleMgr.StableRandom must equal what the + // verbatim engine would compute — floor(val * new System.Random(seed).NextDouble()) — pinning the + // re-authored RandomSourceBridge arithmetic to the engine's own formula+generator. (The default + // source seeds from HeadlessContentsCreator.RandomSeed == 12345.) + [Test] + public void Default_source_matches_engine_generator_and_formula() + { + HeadlessEngineEnv.EnsureInitialized(); + BattleManagerBase.IsForecast = true; + + var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator()); // default SeededRandomSource(12345) + var reference = new System.Random(12345); + + for (int i = 0; i < 10; i++) + { + int expected = (int)System.Math.Floor(7 * reference.NextDouble()); + Assert.That(mgr.StableRandom(7), Is.EqualTo(expected), $"parity drift at roll {i}"); + } + } } } diff --git a/SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs b/SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs new file mode 100644 index 0000000..4074b62 --- /dev/null +++ b/SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs @@ -0,0 +1,42 @@ +using Wizard.BattleMgr; + +namespace SVSim.BattleEngine.Rng +{ + // The headless authoritative single-battle mgr. Overrides the three BattleManagerBase RNG methods + // (now virtual per the Task-4 DP5 patch) to delegate to an injected IRandomSource instead of the + // IsForecast-gated System.Random fields. This is the F2 decoupling: VFX stays suppressed + // (IsForecast == true) while RNG rolls real. The skill RNG path calls BattleManagerBase.GetIns() + // .StableRandom*, and the base ctor registers `this` as the singleton, so constructing the battle as + // HeadlessBattleMgr makes every roll dispatch (virtually) to these overrides. + // + // randomResult is set inside the overrides (it has a protected setter, reachable only from a + // subclass — NOT from RandomSourceBridge); it is read by the Phase-2 NetworkSkill_cost_change emit + // path, so the overrides keep it faithful. The arithmetic itself lives in RandomSourceBridge so it + // stays unit-testable and reusable by a future NetworkBattleManagerBase-derived mgr. + public sealed class HeadlessBattleMgr : SingleBattleMgr + { + private readonly IRandomSource _rng; + + public HeadlessBattleMgr(IBattleMgrContentsCreator contentsCreator, IRandomSource rng = null) + : base(contentsCreator) + { + _rng = rng ?? new SeededRandomSource(contentsCreator.RandomSeed); + } + + public override int StableRandom(int val) + { + double unit = _rng.NextUnit(); + randomResult = unit; + return RandomSourceBridge.Range(val, unit); + } + + public override double StableRandomDouble() + { + double unit = _rng.NextUnit(); + randomResult = unit; + return unit; + } + + public override int StableRandomOnlySelf(int val) => _rng.NextSelf(val); + } +} diff --git a/SVSim.BattleEngine/Rng/RandomSourceBridge.cs b/SVSim.BattleEngine/Rng/RandomSourceBridge.cs index 6240cbd..f2e635c 100644 --- a/SVSim.BattleEngine/Rng/RandomSourceBridge.cs +++ b/SVSim.BattleEngine/Rng/RandomSourceBridge.cs @@ -4,7 +4,7 @@ namespace SVSim.BattleEngine.Rng { // The ONE place engine roll-logic is re-authored (the virtual-override seam restates it rather than // body-patching the Engine file). Isolated here so it is unit-testable and pinned to the verbatim - // engine by the parity test (RngSeamTests.SeededSource_matches_engine_generator / Task 5). Mirrors + // engine by the parity test (RngSeamTests.Default_source_matches_engine_generator_and_formula). Mirrors // BattleManagerBase.StableRandom: `(int)Math.Floor((double)val * unit)`. public static class RandomSourceBridge {