From b13cfa0fada3d9fb0678f8c34155343a44e6a8c8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 02:36:02 -0400 Subject: [PATCH] =?UTF-8?q?test(battle-engine-port):=20M4=20COMPLETE=20?= =?UTF-8?q?=E2=80=94=20when=5Fplay=20self-buff=20follower=20resolves=20hea?= =?UTF-8?q?dless=20(4/4=20green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold SetupCardEvent into a shared HeadlessEngineEnv.CreateHeadlessHandCard primitive (consolidating the duplicated M2/M3 helpers), then add the M4 oracle: card 103111050 (ELF cost-1 1/1, when_play powerup add_offense=1&add_life=1 to target=self). New oracle dimension = the played card's OWN stat delta (1/1 -> 2/2). Gate play_count>2 seeded via the public AddCurrentTrunPlayCount; proven load-bearing (without the seed the fanfare gates out and Atk stays 1). No new shim/data gaps were needed — only harness seeding. Engine still 0 errors; check_drift clean. Co-Authored-By: Claude Opus 4.8 --- .../BuffFollowerOracleTests.cs | 100 ++++++++++++++++++ .../FixedDamageSpellOracleTests.cs | 21 +--- SVSim.BattleEngine.Tests/HeadlessFixture.cs | 43 +++++++- .../VanillaFollowerOracleTests.cs | 13 +-- 4 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs diff --git a/SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs b/SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs new file mode 100644 index 0000000..fd4e461 --- /dev/null +++ b/SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs @@ -0,0 +1,100 @@ +using System.Reflection; +using NUnit.Framework; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M4 (next-hardest deterministic card): a when_play SELF-BUFF follower resolves to correct + // authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the M2 + // vanilla follower and M3 fixed-damage spell proved (design §5 / DP4 + M3 resume recipe). The new + // oracle dimension over M2/M3 is the PLAYED CARD'S OWN STAT DELTA: the fanfare `powerup` + // `add_offense=1&add_life=1` to `target=self` must raise the follower's Atk and Life by exactly + // those amounts over its CardCSVData base — a self-buff, so no target selection is involved. + [TestFixture] + public class BuffFollowerOracleTests + { + private static void SetPrivateField(object obj, string name, object value) + { + var t = obj.GetType(); + var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); } + Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}"); + f.SetValue(obj, value); + } + + [Test] + public void Self_buff_fanfare_raises_own_atk_and_life() + { + HeadlessEngineEnv.EnsureInitialized(); + BattleManagerBase.IsForecast = true; // suppress VFX registration (F1) + var mgr = new SingleBattleMgr(new HeadlessContentsCreator()); + mgr.IsRecovery = true; // collapse wait delays to 0 (F1) + + var player = mgr.BattlePlayer; + var enemy = mgr.BattleEnemy; + + // Minimal opponent/turn wiring (see M2/M3 oracles): opponent refs + active turn flag. The + // self-buff's target resolver (`character=me&target=self`) reads the active player's own + // in-play card, so the turn flag must be set before the fanfare sweeps. + SetPrivateField(player, "_opponentBattlePlayer", enemy); + SetPrivateField(enemy, "_opponentBattlePlayer", player); + player.IsSelfTurn = true; + enemy.IsSelfTurn = false; + + // Seed leader life so neither leader reads as a 0-life game-over state that silently blocks + // the play (M3 learning); this card deals no damage but the play-legality gate still checks it. + HeadlessEngineEnv.InitLeaderLife(mgr); + + // The card's fanfare is gated on `play_count>2` (cards.json skill_condition for 103111050). + // The engine reads this from BattlePlayerBase.GetCurrentTurnPlayCount(); seed it past the + // threshold via the public AddCurrentTrunPlayCount so the powerup actually fires. (Without + // this the card resolves to the board but takes no buff — the delta-vs-base oracle is what + // distinguishes "buff applied" from "fanfare silently gated out".) + player.AddCurrentTrunPlayCount(5); + + var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId); + + // Place the self-buff follower in the active player's hand with PP to spare; empty board. + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.BuffFollowerId, 1, isPlayer: true, mgr); + player.HandCardList.Add(card); + player.Pp = 10; + + // Pre-state snapshot. + int ppBefore = player.Pp; + int handBefore = player.HandCardList.Count; + int inplayBefore = player.ClassAndInPlayCardList.Count; + int enemyHandBefore = enemy.HandCardList.Count; + int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count; + int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life; + + // Resolve the play through the real engine. + var pair = mgr.GetBattlePlayerPair(isPlayer: true); + var ap = new ActionProcessor(pair); + Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null), + "ActionProcessor.PlayCard threw on a self-buff fanfare follower"); + + // Oracle: the own-stat delta is the new M4 dimension; the rest are the §5 follower invariants. + Assert.Multiple(() => + { + // Primary M4 assertion: the fanfare powerup raised the follower's own stats by exactly + // the buff amounts over its CardCSVData base (1/1 -> 2/2). + Assert.That(card.Atk, Is.EqualTo(cardParam.Atk + HeadlessEngineEnv.BuffAddOffense), + "follower atk != base + fanfare add_offense"); + Assert.That(card.Life, Is.EqualTo(cardParam.Life + HeadlessEngineEnv.BuffAddLife), + "follower life != base + fanfare add_life"); + // Cost paid. + Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost"); + // Follower moved hand -> board. + Assert.That(player.HandCardList, Does.Not.Contain(card), "card still in hand"); + Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1"); + Assert.That(player.ClassAndInPlayCardList, Contains.Item(card), "card not in play"); + Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(inplayBefore + 1), "in-play count not +1"); + // Opponent unchanged (the buff targets self, not the opponent). + Assert.That(enemy.HandCardList.Count, Is.EqualTo(enemyHandBefore), "opponent hand changed"); + Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed"); + Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "opponent leader life changed"); + }); + } + } +} diff --git a/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs b/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs index 0eea63b..358befe 100644 --- a/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs +++ b/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs @@ -26,25 +26,6 @@ namespace SVSim.BattleEngine.Tests f.SetValue(obj, value); } - private static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr) - { - // Engine's own null-view creation path (CreateBase -> new *BattleCard(buildInfo).Setup(true)); - // private, so reflect it — same seam the M2 oracle uses. - var io = mgr.CreatePlayerInnerOptionsBuilder(); - var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources", - BindingFlags.NonPublic | BindingFlags.Static); - var card = (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io }); - // The engine's CreateCard wires per-card play events via SetupCardEvent; the raw - // CreateCardWithoutResources path skips it. For a SPELL this matters: SetupCardEvent - // attaches OnPlay -> RemoveSpellCardFromHand and OnFinishWhenPlaySkill -> - // AddSpellCardToCemetery (BattlePlayerBase.cs:1462-1466). Without it the spell resolves - // its damage but never leaves the hand. (Harmless for the M2 follower, whose hand->field - // move is intrinsic to SetUpInplay, not event-driven.) - BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy; - owner.SetupCardEvent(card); - return card; - } - [Test] public void Fixed_damage_spell_reduces_opponent_leader_life() { @@ -70,7 +51,7 @@ namespace SVSim.BattleEngine.Tests var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.SpellId); // Place the spell in the active player's hand with PP to spare; empty board otherwise. - var card = CreateHeadlessHandCard(HeadlessEngineEnv.SpellId, 1, isPlayer: true, mgr); + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.SpellId, 1, isPlayer: true, mgr); player.HandCardList.Add(card); player.Pp = 10; diff --git a/SVSim.BattleEngine.Tests/HeadlessFixture.cs b/SVSim.BattleEngine.Tests/HeadlessFixture.cs index 18cd251..dfcd3af 100644 --- a/SVSim.BattleEngine.Tests/HeadlessFixture.cs +++ b/SVSim.BattleEngine.Tests/HeadlessFixture.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using Wizard; +using Wizard.Battle; using Wizard.Battle.Phase; using Wizard.Battle.Recovery; using Wizard.Battle.Replay; @@ -19,6 +22,16 @@ namespace SVSim.BattleEngine.Tests // (the enemy leader) — auto-targeted (no select_count), no RNG. Deterministic burn to the face. public const int SpellId = 900124030; + // M4 next-hardest deterministic card: a when_play SELF-BUFF follower. 103111050 is an ELF + // (clan 1) cost-1 1/1 whose sole non-evo skill is `when_play` `powerup` `add_offense=1&add_life=1` + // with skill_target `character=me&target=self` — it buffs ITSELF, so no target selection (the + // fanfare auto-resolves). Fixed +1/+1 => a deterministic stat-delta oracle. The skill is gated on + // `play_count>2`; the headless harness seeds that via the public AddCurrentTrunPlayCount (see the + // oracle test). Base 1/1 -> 2/2 after the fanfare. + public const int BuffFollowerId = 103111050; + public const int BuffAddOffense = 1; + public const int BuffAddLife = 1; + private static bool _done; public static void EnsureInitialized() @@ -34,9 +47,9 @@ namespace SVSim.BattleEngine.Tests .SetValue(null, new Wizard.Crossover()); BattleManagerBase.IsForecast = true; // CardMaster must be non-null before construction (the leader/class card looks up id 0). - // Load the M2 vanilla follower + the M3 fixed-damage spell so each oracle can create + - // look up its real stats. - HeadlessCardMaster.Load(FollowerId, SpellId); + // Load the M2 vanilla follower + the M3 fixed-damage spell + the M4 self-buff follower so + // each oracle can create + look up its real stats. + HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId); // Master reference data (class-character list) for leader/class card resolution. HeadlessMasterData.Install(); // Player/enemy leaders (chara ids must map to a ClassCharacterMasterData in Master). @@ -64,6 +77,30 @@ namespace SVSim.BattleEngine.Tests ((ClassBattleCardBase)mgr.BattleEnemy.Class).InitBaseMaxLife(life); } + // The shared headless card-creation primitive. CardCreatorBase.CreateCardWithoutResources is + // the engine's own null-view creation path (CreateBase -> new *BattleCard(buildInfo).Setup( + // createNullView:true)); it's private, so reflect it rather than reimplement the 14-arg + // BuildInfo wiring. The public CardCreatorBase.CreateCard goes through prefab cloning. + // + // The engine's CreateCard also calls owner.SetupCardEvent(card); the raw + // CreateCardWithoutResources seam skips it, so we fold it in here. SetupCardEvent wires the + // per-card play events (BattlePlayerBase.cs:1452): for a SPELL/amulet it attaches + // OnPlay -> RemoveSpellCardFromHand and OnFinishWhenPlaySkill -> AddSpellCardToCemetery, which + // are how a non-follower leaves the hand at all (a follower's hand->field move is intrinsic to + // SetUpInplay, not event-driven). For a follower SetupCardEvent only attaches an OnEvolve hook + // that never fires on a vanilla play, so folding it in is a no-op there — making this a single + // primitive both follower and non-follower oracles can share. + public static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr) + { + var io = mgr.CreatePlayerInnerOptionsBuilder(); + var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources", + BindingFlags.NonPublic | BindingFlags.Static); + var card = (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io }); + BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy; + owner.SetupCardEvent(card); + return card; + } + private static void SetField(object obj, string name, object value) { var f = obj.GetType().GetField(name, diff --git a/SVSim.BattleEngine.Tests/VanillaFollowerOracleTests.cs b/SVSim.BattleEngine.Tests/VanillaFollowerOracleTests.cs index a11e1d7..121d253 100644 --- a/SVSim.BattleEngine.Tests/VanillaFollowerOracleTests.cs +++ b/SVSim.BattleEngine.Tests/VanillaFollowerOracleTests.cs @@ -23,17 +23,6 @@ namespace SVSim.BattleEngine.Tests f.SetValue(obj, value); } - private static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr) - { - // CardCreatorBase.CreateCardWithoutResources is the engine's own null-view creation path - // (CreateBase -> new UnitBattleCard(buildInfo).Setup(createNullView:true)); it's private, - // so reflect it rather than reimplement the 14-arg BuildInfo wiring. - var io = mgr.CreatePlayerInnerOptionsBuilder(); - var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources", - BindingFlags.NonPublic | BindingFlags.Static); - return (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io }); - } - [Test] public void Vanilla_follower_resolves_to_correct_state() { @@ -57,7 +46,7 @@ namespace SVSim.BattleEngine.Tests var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.FollowerId); // Place the follower in the active player's hand with PP to spare; empty board otherwise. - var card = CreateHeadlessHandCard(HeadlessEngineEnv.FollowerId, 1, isPlayer: true, mgr); + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.FollowerId, 1, isPlayer: true, mgr); player.HandCardList.Add(card); player.Pp = 10;