feat(rng-seam): HeadlessBattleMgr override + decoupling/parity tests (F2 resolved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 10:33:59 -04:00
parent 201158db5d
commit c47f8d9fa7
3 changed files with 88 additions and 1 deletions

View File

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

View File

@@ -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);
}
}

View File

@@ -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
{