From 2fd0aac5b6de678896d06832161f128c5f9cfe49 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 10:40:59 -0400 Subject: [PATCH] test(rng-seam): M12 constants + NewAuthoritativeBattle harness factory --- SVSim.BattleEngine.Tests/HeadlessFixture.cs | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/SVSim.BattleEngine.Tests/HeadlessFixture.cs b/SVSim.BattleEngine.Tests/HeadlessFixture.cs index 3bb8d7b..92db08f 100644 --- a/SVSim.BattleEngine.Tests/HeadlessFixture.cs +++ b/SVSim.BattleEngine.Tests/HeadlessFixture.cs @@ -1,4 +1,5 @@ using System.Reflection; +using SVSim.BattleEngine.Rng; using UnityEngine; using Wizard; using Wizard.Battle; @@ -150,6 +151,26 @@ namespace SVSim.BattleEngine.Tests // (M4/M6/M8 discipline) varies this and watches the damage track it. public const int DynamicSeededPlayCount = 4; + // M12 (the design ยง5 RNG oracle): reuse the M9 draw spell (800114010, when_play `draw` 1 from the + // caster's deck via a random_count=1 filter) but over a MULTI-card deck with IsRandomDraw=true. + // M9 passed only because IsRandomDraw=false takes BattlePlayerBase.LotteryRandomDrawCard's + // top-of-deck `else` branch (BattlePlayerBase.cs:3174-3185) โ€” a 1-card pool made index 0 the only + // card. With IsRandomDraw=true the selection runs through SkillRandomSelectFilter.Filtering, which + // calls BattleManagerBase.GetIns().StableRandom(poolCount) per pick (SkillRandomSelectFilter.cs:42, + // gated on IsRandomDraw) โ€” the chokepoint HeadlessBattleMgr overrides. So the scripted source picks + // exactly which deck card is drawn, proving a GENUINE multi-outcome roll (the dimension M9's + // one-card pool deliberately avoided). + // + // Three distinguishable deck cards seeded at consecutive indices; SkillRandomSelectFilter orders + // the pool by Index (line 34), so the pick index maps to position in this order: + // index 0 -> RngDeckCardA (100011010), index 1 -> RngDeckCardB (103111050), index 2 -> RngDeckCardC (100011020) + // All three are already loaded by HeadlessCardMaster.Load via EnsureInitialized (FollowerId, + // BuffFollowerId, SummonedTokenId), so no Load change is needed. + public const int RngDrawSpellId = DrawSpellId; // 800114010, when_play draw 1 (random_count=1) + public const int RngDeckCardA = FollowerId; // neutral 1/2 -> Index-order position 0 + public const int RngDeckCardB = BuffFollowerId; // ELF 1/1 -> Index-order position 1 + public const int RngDeckCardC = SummonedTokenId; // neutral 2/2 -> Index-order position 2 + private static bool _done; public static void EnsureInitialized() @@ -284,6 +305,30 @@ namespace SVSim.BattleEngine.Tests return card; } + // Build a headless battle wired for AUTHORITATIVE RNG: real rolls under IsForecast (via the + // injected source on HeadlessBattleMgr) AND IsRandomDraw=true (the second gate โ€” without it the + // random-select filters bypass the roll and pick index 0; BattleManagerBase.cs:415, + // SkillRandomSelectFilter.cs:42). Mirrors the opponent/turn/leader-life wiring every oracle does. + // Returns the constructed HeadlessBattleMgr; the caller seeds hands/decks/boards and plays. + public static HeadlessBattleMgr NewAuthoritativeBattle(IRandomSource rng) + { + EnsureInitialized(); // sets IsForecast = true among other globals + BattleManagerBase.IsRandomDraw = true; // the second RNG gate (F-RNG-2) + var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng); + mgr.IsRecovery = true; // collapse wait delays to 0 (F1) + + var player = mgr.BattlePlayer; + var enemy = mgr.BattleEnemy; + SetField(player, "_opponentBattlePlayer", enemy); + SetField(enemy, "_opponentBattlePlayer", player); + player.IsSelfTurn = true; + enemy.IsSelfTurn = false; + + InitLeaderLife(mgr); // a 0-life leader reads as game-over and blocks plays + InitCardTemplates(mgr); // the draw VFX touches the drawn card's view layer + return mgr; + } + private static void SetField(object obj, string name, object value) { var f = obj.GetType().GetField(name,