diff --git a/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs b/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs new file mode 100644 index 0000000..0eea63b --- /dev/null +++ b/SVSim.BattleEngine.Tests/FixedDamageSpellOracleTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M3 (next-hardest deterministic card): a FIXED-DAMAGE SPELL resolves to correct authoritative + // state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the M2 vanilla + // follower proved (design §5 / DP4 + M3 resume recipe). The new oracle dimension over M2 is the + // OPPONENT LEADER-LIFE DELTA: the spell's when_play `damage=3` to the enemy leader must reduce + // that leader's Life by exactly 3, with the spell consuming its cost and NOT entering the board. + [TestFixture] + public class FixedDamageSpellOracleTests + { + // The spell's sole skill is `damage=3` to the enemy leader (cards.json skill_option for 900124030). + private const int ExpectedLeaderDamage = 3; + + 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); + } + + 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() + { + 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 oracle): opponent refs + active turn flag. The + // spell's target resolver walks player -> opponent -> opponent's class card (the leader). + SetPrivateField(player, "_opponentBattlePlayer", enemy); + SetPrivateField(enemy, "_opponentBattlePlayer", player); + player.IsSelfTurn = true; + enemy.IsSelfTurn = false; + + // Seed leader life (engine's InitializeClassLife subset) so the enemy leader is a live, + // damageable target rather than a 0-life game-over state that blocks the play. + HeadlessEngineEnv.InitLeaderLife(mgr); + + 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); + 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 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 fixed-damage spell"); + + // Oracle: the leader-life delta is the new M3 dimension; the rest are the §5 spell-shaped invariants. + Assert.Multiple(() => + { + // Primary M3 assertion: opponent leader takes exactly the spell's fixed damage. + Assert.That(enemy.ClassAndInPlayCardList[0].Life, + Is.EqualTo(enemyLeaderLifeBefore - ExpectedLeaderDamage), + "opponent leader life not reduced by the spell's fixed damage"); + // Cost paid. + Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost"); + // Spell leaves hand. + 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"); + // A spell is not a follower: it must NOT occupy the board (resolves to graveyard). + 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"); + // Opponent board (leader card only) count unchanged — only its life moved. + Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board count changed"); + }); + } + } +} diff --git a/SVSim.BattleEngine.Tests/HeadlessFixture.cs b/SVSim.BattleEngine.Tests/HeadlessFixture.cs index aaa2633..18cd251 100644 --- a/SVSim.BattleEngine.Tests/HeadlessFixture.cs +++ b/SVSim.BattleEngine.Tests/HeadlessFixture.cs @@ -14,6 +14,11 @@ namespace SVSim.BattleEngine.Tests // Simplest zero-skill vanilla follower in cards.json: neutral (clan 0), cost 1, 1/2, no skill. public const int FollowerId = 100011010; + // M3 next-hardest deterministic card: a fixed-damage spell. 900124030 is an ELF (clan 1, matches + // PlayerClassId) cost-3 spell whose sole skill is `when_play` `damage=3` to `card_type=class` + // (the enemy leader) — auto-targeted (no select_count), no RNG. Deterministic burn to the face. + public const int SpellId = 900124030; + private static bool _done; public static void EnsureInitialized() @@ -29,8 +34,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 so the oracle can create + look up its real stats. - HeadlessCardMaster.Load(FollowerId); + // Load the M2 vanilla follower + the M3 fixed-damage spell so each oracle can create + + // look up its real stats. + HeadlessCardMaster.Load(FollowerId, SpellId); // 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). @@ -43,6 +49,21 @@ namespace SVSim.BattleEngine.Tests _done = true; } + // Seed each leader's starting life on a freshly-constructed mgr. The engine does this in + // BattleManagerBase.SetupInitialGameState -> InitializeClassLife (InitBaseMaxLife per leader), + // but the full SetupInitialGameState also cascades into rotation/avatar/turn-panel UI init + // that is irrelevant (and hostile) to a headless resolution test, so apply just the + // InitializeClassLife subset. Without this a leader's BaseMaxLife defaults to 0 — which reads + // as already-dead/game-over and silently blocks any card play (the M2 follower oracle never + // noticed because it only asserted leader life *unchanged*, and 0 == 0). + public const int DefaultLeaderLife = 20; + + public static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife) + { + ((ClassBattleCardBase)mgr.BattlePlayer.Class).InitBaseMaxLife(life); + ((ClassBattleCardBase)mgr.BattleEnemy.Class).InitBaseMaxLife(life); + } + private static void SetField(object obj, string name, object value) { var f = obj.GetType().GetField(name, diff --git a/SVSim.BattleEngine/COPIED.manifest.tsv b/SVSim.BattleEngine/COPIED.manifest.tsv index ae91ec1..66623dc 100644 --- a/SVSim.BattleEngine/COPIED.manifest.tsv +++ b/SVSim.BattleEngine/COPIED.manifest.tsv @@ -3311,3 +3311,4 @@ llField.cs llField.cs a0e0eaed3f22a8c4ce47f82fa80346e3b99e3ac0a6765e1ad4ade3a87c Wizard.Battle.View/CardIconControl.cs Wizard.Battle.View/CardIconControl.cs affd5a289a04bc9f446f3e892403dd9cd560ee557de1e5cd743dcb031dab280c 0 Wizard.Battle.View.Vfx/NullCardVfxCreator.cs Wizard.Battle.View.Vfx/NullCardVfxCreator.cs bf8f34d27f41df0dc728c47f874465869649299a5195c2d616597d7a37c581f5 0 Wizard.Battle.View.Vfx/NotEmptyNullVfx.cs Wizard.Battle.View.Vfx/NotEmptyNullVfx.cs fd471e4254bde6dded2c1447714e605a9889b97b01a834bc616585bcff738825 0 +Wizard.Battle.View.Vfx/NullVfxWithLoading.cs Wizard.Battle.View.Vfx/NullVfxWithLoading.cs c297c7c7e53fc9a41e46b5f52baa65f905bc51943d6a0fbe683a98fc0668b9b9 0 diff --git a/SVSim.BattleEngine/Engine/Wizard.Battle.View.Vfx/NullVfxWithLoading.cs b/SVSim.BattleEngine/Engine/Wizard.Battle.View.Vfx/NullVfxWithLoading.cs new file mode 100644 index 0000000..eb29fe6 --- /dev/null +++ b/SVSim.BattleEngine/Engine/Wizard.Battle.View.Vfx/NullVfxWithLoading.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace Wizard.Battle.View.Vfx; + +public class NullVfxWithLoading : VfxWithLoading +{ + private static NullVfxWithLoading _instance; + + public override VfxBase LoadingVfx => NullVfx.GetInstance(); + + public override VfxBase MainVfx => NullVfx.GetInstance(); + + public override bool IsEnd => true; + + public static NullVfxWithLoading GetInstance() + { + if (_instance == null) + { + _instance = new NullVfxWithLoading(); + } + return _instance; + } + + public override void Play() + { + } + + public override void Update(float dt, List effectVfxList) + { + } + + public override bool IsVfxNonEmpty() + { + return false; + } +} diff --git a/SVSim.BattleEngine/Shim/Generated/NullVfxWithLoading.g.cs b/SVSim.BattleEngine/Shim/Generated/NullVfxWithLoading.g.cs deleted file mode 100644 index efb6aae..0000000 --- a/SVSim.BattleEngine/Shim/Generated/NullVfxWithLoading.g.cs +++ /dev/null @@ -1,16 +0,0 @@ -// AUTO-GENERATED no-op stubs (m1_stub_gen) from Shadowverse_Code_2026-05-23\Wizard.Battle.View.Vfx\NullVfxWithLoading.cs -using System.Collections.Generic; -namespace Wizard.Battle.View.Vfx -{ -public partial class NullVfxWithLoading -{ - private static NullVfxWithLoading _instance; - public VfxBase LoadingVfx { get; set; } - public VfxBase MainVfx { get; set; } - public bool IsEnd { get; set; } - public static NullVfxWithLoading GetInstance() => default!; - public void Play() { } - public void Update(float dt, List effectVfxList) { } - public bool IsVfxNonEmpty() => default!; -} -} diff --git a/SVSim.BattleEngine/Shim/Generated/_BaseClauses.g.cs b/SVSim.BattleEngine/Shim/Generated/_BaseClauses.g.cs index 7b490b6..1e7ad36 100644 --- a/SVSim.BattleEngine/Shim/Generated/_BaseClauses.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/_BaseClauses.g.cs @@ -101,7 +101,6 @@ namespace Wizard.Battle.View { public partial class NullClassBattleCardView : Nu namespace Wizard.Battle.View { public partial class NullEnemyBattleView : BattleEnemyView { } } namespace Wizard.Battle.View { public partial class NullFieldBattleCardView : FieldBattleCardView { } } namespace Wizard.Battle.View { public partial class NullPlayerBattleView : BattlePlayerView { } } -namespace Wizard.Battle.View.Vfx { public partial class NullVfxWithLoading : VfxWithLoading { } } namespace Wizard.Battle.View.Vfx { public partial class OneShotHeavenlyAegisPlayVfx : SequentialVfxPlayer { } } namespace Wizard.Battle.View.Vfx { public partial class OpenCardFromHandVfx : SequentialVfxPlayer { } } namespace Wizard.Battle.View.Vfx { public partial class OpenCardVfx : SequentialVfxPlayer { } } diff --git a/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs b/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs index 6164073..c3da8ac 100644 --- a/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs +++ b/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs @@ -61,6 +61,16 @@ namespace Wizard.Battle.View public virtual void ClearSpineObject() { } } public class NullBattleCardView : BattleCardView { public NullBattleCardView() { } public NullBattleCardView(BuildInfo buildInfo) { } public static void ReleaseSharedDummy() { } } + + // The decomp NullClassBattleCardView is `: NullBattleCardView, IClassBattleCardView, IBattleCardView`; + // base-clause recovery kept only the base class. IBattleCardView is satisfied via the BattleCardView + // base, but IClassBattleCardView was dropped. The generated NullClassBattleCardView stub already + // provides that interface's members (public no-ops), so just re-attach the dropped interface here. + // The resolution path's VirtualClone (createNullView) -> ClassBattleCardBase.Setup casts the null + // view to IClassBattleCardView, which throws InvalidCastException at runtime without this (M3, + // fixed-damage spell: Skill_damage.TakeDamageSingle clones the leader before applying damage). + // Compiles fine without it (it's a cast, not a member call), so the M1 loop never surfaced it. + public partial class NullClassBattleCardView : IClassBattleCardView { } } namespace Wizard.Battle.View.Vfx