From c8314bd3c0909dc98f1ccd350dde41b9dde7ef90 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 08:08:01 -0400 Subject: [PATCH] =?UTF-8?q?test(battle-engine-port):=20M6=20COMPLETE=20?= =?UTF-8?q?=E2=80=94=20targeted=20when=5Fplay=20damage=20spell=20resolves?= =?UTF-8?q?=20headless=20(selection-routing=20oracle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First card to exercise the selectedCards path of ActionProcessor.PlayCard (dormant through M2-M5, all of which played selectedCards: null). Spell 800134020 (clan-1 cost-1, when_play damage=5 to a select_count=1 enemy follower) resolves headless: with two vanilla followers on the enemy board and one passed as selectedCards, the damage hits ONLY the selected follower (13->8) and the un-selected one is untouched (7). New oracle dimension: SELECTION ROUTING via a differential life-delta on two surviving targets (selected -5, un-selected 0) — reads the authoritative damage path M3 proved, with no dependence on follower death/board-removal timing. Load-bearing confirmed (M4 discipline): swapping which follower is selected makes the damage follow the selection (assertions fail for the right reason), then reverted to green. Like M4, a clean milestone: NO new engine/shim work — the selectedCards path resolved on the existing shim surface. The only authoring was test-side: the M6 card constants, a shared HeadlessEngineEnv.PutFollowerInPlay primitive (create via the null-view seam + drive HandCardToField), and the oracle. Engine still 0 errors; check_drift clean; dotnet test -> 6/6 green. Co-Authored-By: Claude Opus 4.8 --- SVSim.BattleEngine.Tests/HeadlessFixture.cs | 38 ++++++- .../TargetedDamageSpellOracleTests.cs | 104 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 SVSim.BattleEngine.Tests/TargetedDamageSpellOracleTests.cs diff --git a/SVSim.BattleEngine.Tests/HeadlessFixture.cs b/SVSim.BattleEngine.Tests/HeadlessFixture.cs index fc98b09..0ed10f6 100644 --- a/SVSim.BattleEngine.Tests/HeadlessFixture.cs +++ b/SVSim.BattleEngine.Tests/HeadlessFixture.cs @@ -50,6 +50,25 @@ namespace SVSim.BattleEngine.Tests public const int SummonedTokenAtk = 2; public const int SummonedTokenLife = 2; + // M6 next milestone: the first card requiring TARGET SELECTION — exercises the selectedCards + // path of ActionProcessor.PlayCard (dormant through M2-M5, all of which played + // selectedCards: null). 800134020 is an ELF (clan 1) cost-1 SPELL whose sole skill is + // `when_play` `damage=5` to a SELECTED enemy follower + // (skill_target=character=op&target=inplay&card_type=unit&select_count=1), ungated + // (character=me), no RNG, no dynamic `{}` value. The new oracle dimension is SELECTION + // ROUTING: with TWO followers on the enemy board and ONE passed as selectedCards, only the + // selected follower takes the 5 damage and the un-selected one is untouched. + public const int TargetSpellId = 800134020; + public const int TargetSpellDamage = 5; + + // Two zero-skill vanilla NEUTRAL followers placed on the ENEMY board. Both have life > the + // 5 damage so they SURVIVE — this gives a differential life-delta oracle (selected -5, + // un-selected -0) that reads the authoritative damage path M3 already proved, without + // depending on follower death/board-removal timing (a separate, unproven mechanic). Distinct + // base life (13 vs 7) so the two post-states can't coincidentally match. + public const int SelectTargetFollowerId = 900041010; // neutral 13/13 + public const int UnselectTargetFollowerId = 102011010; // neutral 6/7 + private static bool _done; public static void EnsureInitialized() @@ -69,7 +88,8 @@ namespace SVSim.BattleEngine.Tests // the M5 summon-token spell AND the token it summons so each oracle can create + look up // real stats. The summoned token id must be present: Skill_summon_token resolves it // through CardMaster.GetCardParameterFromId during creation. - HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId, TokenSpellId, SummonedTokenId); + HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId, TokenSpellId, SummonedTokenId, + TargetSpellId, SelectTargetFollowerId, UnselectTargetFollowerId); // 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). @@ -149,6 +169,22 @@ namespace SVSim.BattleEngine.Tests return card; } + // Put a follower DIRECTLY onto a player's board headless (vs as a side-effect of PlayCard), + // for setting up a target board state. Create it through the shared null-view seam, then drive + // the engine's own hand->field move: HandCardToField requires the card to be in HandCardList, + // then AddInplayCards it + removes it from hand (BattlePlayerBase.cs:2568). For a vanilla + // follower the OnAddPlayCard/StopBattleHandCard/OnSummonAfter events it fires are no-ops (no + // fanfare), so the follower lands on the board at its CardCSVData base stats. M2 proved the + // hand->field placement path resolves headless. + public static BattleCardBase PutFollowerInPlay(BattleManagerBase mgr, int cardId, int index, bool isPlayer) + { + var card = CreateHeadlessHandCard(cardId, index, isPlayer, mgr); + BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy; + owner.HandCardList.Add(card); + owner.HandCardToField(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/TargetedDamageSpellOracleTests.cs b/SVSim.BattleEngine.Tests/TargetedDamageSpellOracleTests.cs new file mode 100644 index 0000000..b1a0ffc --- /dev/null +++ b/SVSim.BattleEngine.Tests/TargetedDamageSpellOracleTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M6 (the first TARGET-SELECTION card): a when_play TARGETED-DAMAGE spell resolves to correct + // authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the + // M2-M5 cards proved — but for the FIRST time exercising the `selectedCards` path of + // ActionProcessor.PlayCard (Engine/Wizard.Battle/ActionProcessor.cs:401, dormant until now; + // M2-M5 all passed selectedCards: null). The new oracle dimension is SELECTION ROUTING: with + // TWO followers on the enemy board and ONE passed as `selectedCards`, the spell's `damage=5` + // must hit the SELECTED follower and leave the un-selected one untouched. A plain "a follower + // took damage" assertion would false-pass; reading the differential (selected -5, un-selected 0) + // is what proves the selectedCards path routes the effect to the chosen target. Load-bearing is + // confirmed by swapping which follower is selected and watching the damage follow the selection. + [TestFixture] + public class TargetedDamageSpellOracleTests + { + 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 Targeted_damage_spell_hits_only_the_selected_enemy_follower() + { + 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-M5 oracles): opponent refs + active turn flag. The + // spell's target resolver walks player -> opponent -> opponent's in-play followers. + 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 (blocks plays, M3). + HeadlessEngineEnv.InitLeaderLife(mgr); + + // Put TWO vanilla followers on the ENEMY board (the new M6 setup). Both survive the 5 + // damage, so the oracle reads a differential life-delta rather than depending on death. + var selected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.SelectTargetFollowerId, 0, isPlayer: false); + var unselected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.UnselectTargetFollowerId, 1, isPlayer: false); + + var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.TargetSpellId); + + // Place the targeted-damage spell in the active player's hand with PP to spare. + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.TargetSpellId, 1, isPlayer: true, mgr); + player.HandCardList.Add(card); + player.Pp = 10; + + // Pre-state snapshot. + int ppBefore = player.Pp; + int handBefore = player.HandCardList.Count; + int playerInplayBefore = player.ClassAndInPlayCardList.Count; + int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count; + int selectedLifeBefore = selected.Life; + int unselectedLifeBefore = unselected.Life; + int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life; + + // Resolve the play through the real engine, passing the chosen target via selectedCards + // (the M6 first — every prior milestone passed null). + var pair = mgr.GetBattlePlayerPair(isPlayer: true); + var ap = new ActionProcessor(pair); + Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: new List { selected }), + "ActionProcessor.PlayCard threw on a targeted-damage spell"); + + // Oracle: selection routing is the new M6 dimension; the rest are the §5 spell-shaped invariants. + Assert.Multiple(() => + { + // PRIMARY M6 assertions: the SELECTED follower takes exactly the spell's damage... + Assert.That(selected.Life, Is.EqualTo(selectedLifeBefore - HeadlessEngineEnv.TargetSpellDamage), + "selected follower did not take the spell's damage"); + // ...and the UN-SELECTED follower is untouched (proves routing, not a blanket hit). + Assert.That(unselected.Life, Is.EqualTo(unselectedLifeBefore), + "un-selected follower was damaged (effect not routed to the selection)"); + // Both followers survive => still on the enemy board; leader unchanged. + Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), + "enemy board count changed (a target unexpectedly left the board)"); + Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), + "opponent leader life changed (damage hit the leader, not the selected follower)"); + // Cost paid. + Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost"); + // Spell leaves hand and (being a spell) does NOT occupy the board. + Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand"); + Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1"); + Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board"); + Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed"); + }); + } + } +}