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:
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using SVSim.BattleEngine.Rng;
|
using SVSim.BattleEngine.Rng;
|
||||||
|
using Wizard;
|
||||||
|
using Wizard.Battle;
|
||||||
|
|
||||||
namespace SVSim.BattleEngine.Tests
|
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), Is.EqualTo(3));
|
||||||
Assert.That(() => src.NextSelf(99), Throws.InvalidOperationException, "should throw on self overrun");
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs
Normal file
42
SVSim.BattleEngine/Rng/HeadlessBattleMgr.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// 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)`.
|
// BattleManagerBase.StableRandom: `(int)Math.Floor((double)val * unit)`.
|
||||||
public static class RandomSourceBridge
|
public static class RandomSourceBridge
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user