From ffc0fcaa43cd6368055ec73fae4f39fb03fd37fc Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 10:46:35 -0400 Subject: [PATCH] =?UTF-8?q?test(rng-seam):=20M12=20oracle=20=E2=80=94=20sc?= =?UTF-8?q?ripted=20RNG=20draws=20a=20known=20deck=20card=20(genuine=20mul?= =?UTF-8?q?ti-outcome=20roll)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RandomDrawOracleTests.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 SVSim.BattleEngine.Tests/RandomDrawOracleTests.cs diff --git a/SVSim.BattleEngine.Tests/RandomDrawOracleTests.cs b/SVSim.BattleEngine.Tests/RandomDrawOracleTests.cs new file mode 100644 index 0000000..d1bacd9 --- /dev/null +++ b/SVSim.BattleEngine.Tests/RandomDrawOracleTests.cs @@ -0,0 +1,67 @@ +using System.Linq; +using NUnit.Framework; +using SVSim.BattleEngine.Rng; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M12: the first card whose outcome is a GENUINE RNG roll. The M9 draw spell over a 3-card deck with + // IsRandomDraw=true selects via SkillRandomSelectFilter -> GetIns().StableRandom(poolCount), which + // HeadlessBattleMgr routes to the injected ScriptedRandomSource. The oracle asserts the engine drew + // EXACTLY the card the scripted roll selects, and (load-bearing) that the pick TRACKS the script: + // a different scripted unit draws a different card. This is the multi-outcome roll M9's one-card pool + // deliberately neutralized — it requires the F2 decoupling (real rolls under IsForecast) AND the + // IsRandomDraw=true second gate, both delivered by NewAuthoritativeBattle. + [TestFixture] + public class RandomDrawOracleTests + { + // Draw with a single scripted unit; return (drawnCardId, deckCountAfter). The deck is seeded with + // three distinguishable cards at indices 2,3,4 -> Index-order positions 0,1,2 map to + // RngDeckCardA/B/C. The draw makes one StableRandom(3) call -> index = floor(3*unit). + private static (int drawnId, int deckAfter) DrawWith(double unit) + { + var mgr = HeadlessEngineEnv.NewAuthoritativeBattle(new ScriptedRandomSource(new[] { unit })); + var player = mgr.BattlePlayer; + + HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardA, index: 2, isPlayer: true); + HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardB, index: 3, isPlayer: true); + HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardC, index: 4, isPlayer: true); + + var spell = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.RngDrawSpellId, 1, isPlayer: true, mgr); + player.HandCardList.Add(spell); + player.Pp = 10; + + var pair = mgr.GetBattlePlayerPair(isPlayer: true); + var ap = new ActionProcessor(pair); + Assert.DoesNotThrow(() => ap.PlayCard(spell, selectedCards: null), "PlayCard threw on the random draw"); + + // The drawn card is the new hand entry that is not the spell. + var drawn = player.HandCardList.Single(c => c.CardId != HeadlessEngineEnv.RngDrawSpellId); + return (drawn.CardId, player.DeckCardList.Count); + } + + [Test] + public void Random_draw_picks_the_scripted_card() + { + // unit 0.5 -> floor(3*0.5)=1 -> Index-order position 1 -> RngDeckCardB. + var (drawnId, deckAfter) = DrawWith(0.5); + Assert.Multiple(() => + { + Assert.That(drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardB), + "scripted roll 0.5 should draw the middle (Index-order position 1) deck card"); + Assert.That(deckAfter, Is.EqualTo(2), "deck should be 3 -> 2 after drawing one"); + }); + } + + [Test] + public void Random_draw_pick_tracks_the_scripted_roll() + { + // Load-bearing: varying the scripted unit must move the pick across all three positions. + // floor(3*0.0)=0 -> A ; floor(3*0.5)=1 -> B ; floor(3*0.9)=2 -> C. + Assert.That(DrawWith(0.0).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardA), "0.0 -> position 0"); + Assert.That(DrawWith(0.5).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardB), "0.5 -> position 1"); + Assert.That(DrawWith(0.9).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardC), "0.9 -> position 2"); + } + } +}