diff --git a/SVSim.BattleEngine/Shim/Generated/BattleCardView_BuildInfo.g.cs b/SVSim.BattleEngine/Shim/Generated/BattleCardView_BuildInfo.g.cs index 69b8809..0ed0f12 100644 --- a/SVSim.BattleEngine/Shim/Generated/BattleCardView_BuildInfo.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/BattleCardView_BuildInfo.g.cs @@ -27,7 +27,14 @@ public partial class BuildInfo public Func _getIsAbleToAttack; public Func _getIsUnableToAttackClass; public Func getHandCardFrameEffectType; - public BuildInfo(IReadOnlyBattleCardInfo cardInfo, BattlePlayerReadOnlyInfoPair playerInfoPair, GameObject gameObject, BattleCamera battleCamera, BackGroundBase backGround, IBattleResourceMgr resourceMgr, Func getIsTouchable, Func getIsMovable, Func getIsOnMove, Func getIsFixedUseEnable, Func getIsActionCard, Func getIsAbleToAttack, Func getIsUnableToAttackClass, Func getHandCardFrameEffectType) { } + // HEADLESS-FIX (M-HC-4a): store cardInfo so the headless BattleCardView can expose CardInfo (the + // backing card) — the receive ATTACK path reads BattleCardView.CardInfo.IsClass via + // AttackSelectControl.IsCardTranslatable. (Generated stub body was empty; re-apply on regen.) + public BuildInfo(IReadOnlyBattleCardInfo cardInfo, BattlePlayerReadOnlyInfoPair playerInfoPair, GameObject gameObject, BattleCamera battleCamera, BackGroundBase backGround, IBattleResourceMgr resourceMgr, Func getIsTouchable, Func getIsMovable, Func getIsOnMove, Func getIsFixedUseEnable, Func getIsActionCard, Func getIsAbleToAttack, Func getIsUnableToAttackClass, Func getHandCardFrameEffectType) + { + this.cardInfo = cardInfo; + this._playerInfoPair = playerInfoPair; + } } } } diff --git a/SVSim.BattleEngine/Shim/Generated/EnemyClassBattleCardView.g.cs b/SVSim.BattleEngine/Shim/Generated/EnemyClassBattleCardView.g.cs index fbb8ca5..2b6b381 100644 --- a/SVSim.BattleEngine/Shim/Generated/EnemyClassBattleCardView.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/EnemyClassBattleCardView.g.cs @@ -9,7 +9,7 @@ public partial class EnemyClassBattleCardView private readonly PlayerClassCharacter _classCharacter; public IClassCharacter ClassCharacter { get; set; } public float OriginalRootYPosition { get; set; } - public EnemyClassBattleCardView(BuildInfo buildInfo) { } + public EnemyClassBattleCardView(BuildInfo buildInfo) : base(buildInfo) { } // HEADLESS-FIX (M-HC-4a): chain BuildInfo so the leader view's CardInfo resolves (attack-on-leader targetting) public void StartOutFrame() { } public void StartIntoFrame() { } public float GetCurrentClipTime() => default!; diff --git a/SVSim.BattleEngine/Shim/Generated/PlayerClassBattleCardView.g.cs b/SVSim.BattleEngine/Shim/Generated/PlayerClassBattleCardView.g.cs index 8f990db..2b70d15 100644 --- a/SVSim.BattleEngine/Shim/Generated/PlayerClassBattleCardView.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/PlayerClassBattleCardView.g.cs @@ -9,7 +9,7 @@ public partial class PlayerClassBattleCardView private readonly PlayerClassCharacter _classCharacter; public IClassCharacter ClassCharacter { get; set; } public float OriginalRootYPosition { get; set; } - public PlayerClassBattleCardView(BuildInfo buildInfo) { } + public PlayerClassBattleCardView(BuildInfo buildInfo) : base(buildInfo) { } // HEADLESS-FIX (M-HC-4a): chain BuildInfo so the leader view's CardInfo resolves (attack-on-leader targetting) public void StartOutFrame() { } public void StartIntoFrame() { } public float GetCurrentClipTime() => default!; diff --git a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs index 5386bb8..3d69752 100644 --- a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs @@ -11,7 +11,7 @@ namespace Wizard.Battle.View { Vector3 global::Wizard.Battle.View.IBattleCardView.ForecastIconPosition { get => default!; } Vector3 global::Wizard.Battle.View.IBattleCardView.ForecastIconScale { get => default!; } float global::Wizard.Battle.View.IBattleCardView.OriginalRootYPosition { get => default!; } - IReadOnlyBattleCardInfo global::Wizard.Battle.View.IBattleCardView.CardInfo { get => default!; } + IReadOnlyBattleCardInfo global::Wizard.Battle.View.IBattleCardView.CardInfo { get => HeadlessCardInfo; } // HEADLESS-FIX (M-HC-4a): the backing card (from BuildInfo) — AttackSelectControl.IsCardTranslatable reads CardInfo.IsClass BattlePlayerReadOnlyInfoPair global::Wizard.Battle.View.IBattleCardView.PlayerInfoPair { get => default!; } IReadOnlyVoiceInfo global::Wizard.Battle.View.IBattleCardView.VoiceInfo { get => global::Wizard.Battle.View.HeadlessVoiceInfo.Instance; } // HEADLESS-FIX (M7): non-null voice info for the death-voice tail GameObject global::Wizard.Battle.View.IBattleCardView.GameObject { get => default!; } @@ -27,7 +27,7 @@ namespace Wizard.Battle.View { BackGroundBase global::Wizard.Battle.View.IBattleCardView.m_BackGround { get => default!; } HandParameter global::Wizard.Battle.View.IBattleCardView.HandParam { get => default!; } BattleCardView.AttackTargetSelectInfo global::Wizard.Battle.View.IBattleCardView._attackTargetSelectInfo { get => default!; set { } } - InPlayCardFrameEffectControl global::Wizard.Battle.View.IBattleCardView._inPlayFrameEffect { get => default!; set { } } + InPlayCardFrameEffectControl global::Wizard.Battle.View.IBattleCardView._inPlayFrameEffect { get => _headlessInPlayFrameEffect; set { _headlessInPlayFrameEffect = value; } } // HEADLESS-FIX (M-HC-4a): non-null no-op frame-effect control (HideFrameEffect/UpdateCanAttackEffect are no-ops) for the receive ATTACK path bool global::Wizard.Battle.View.IBattleCardView.areArrowsForcedOff { get => default!; set { } } bool global::Wizard.Battle.View.IBattleCardView._isCardQueuedToBePlayed { get => default!; set { } } bool global::Wizard.Battle.View.IBattleCardView.isHiddenFromHandView { get => default!; set { } } @@ -228,7 +228,7 @@ namespace Wizard.Battle.View { // shared no-op PlayQueueViewBase so the presentation call is a safe NullVfx (the authoritative // play mutation runs in PlayHandCardReflection.Play, not in this view). PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => global::Wizard.Battle.View.HeadlessPlayQueueViewStub.Instance; } - AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => default!; } + AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => global::Wizard.Battle.View.HeadlessAttackSelectControl.Instance; } // HEADLESS-FIX (M-HC-4a): non-null no-op attack-select-control for the receive ATTACK path (RegisterPairToAttackSelectControl + ActionProcessor.Attack reset arm) InPlayViewBase global::Wizard.Battle.View.IBattlePlayerView.InPlayView { get => default!; } GameObject global::Wizard.Battle.View.IBattlePlayerView.StatusParentPanel { get => default!; } GameObject global::Wizard.Battle.View.IBattlePlayerView.AnchorL { get => default!; } @@ -335,7 +335,7 @@ namespace Wizard.Battle.View { // shared no-op PlayQueueViewBase so the presentation call is a safe NullVfx (the authoritative // play mutation runs in PlayHandCardReflection.Play, not in this view). PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => global::Wizard.Battle.View.HeadlessPlayQueueViewStub.Instance; } - AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => default!; } + AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => global::Wizard.Battle.View.HeadlessAttackSelectControl.Instance; } // HEADLESS-FIX (M-HC-4a): non-null no-op attack-select-control for the receive ATTACK path (RegisterPairToAttackSelectControl + ActionProcessor.Attack reset arm) InPlayViewBase global::Wizard.Battle.View.IBattlePlayerView.InPlayView { get => default!; } GameObject global::Wizard.Battle.View.IBattlePlayerView.StatusParentPanel { get => default!; } GameObject global::Wizard.Battle.View.IBattlePlayerView.AnchorL { get => default!; } diff --git a/SVSim.BattleEngine/Shim/View/HeadlessAttackSelectControl.cs b/SVSim.BattleEngine/Shim/View/HeadlessAttackSelectControl.cs new file mode 100644 index 0000000..849f714 --- /dev/null +++ b/SVSim.BattleEngine/Shim/View/HeadlessAttackSelectControl.cs @@ -0,0 +1,38 @@ +// AUTHORED SHIM (not copied) — headless no-op AttackSelectControl for the receive ATTACK path. +// +// The attack conductor dereferences BattlePlayer.BattleView.AttackSelectControl twice on the resolve +// path: InPlayCardReflection.RegisterPairToAttackSelectControl (isPlayer attacks only) calls +// IsCardTranslatable(targetView) + RegisterAttackPair(pair); ActionProcessor.Attack's non-proceeding +// arm calls ResetCardAfterAttack(view). Headless those touch the (UI-only) attack-pair animation state +// — purely cosmetic translate/idle tweening — so a no-op subclass keeps authoritative state intact. +// +// IsCardTranslatable is NOT virtual (it reads cardView.CardInfo.IsClass), so it is left to the BASE +// impl; it resolves correctly headless because the headless BattleCardView's CardInfo is wired to the +// backing card (see Shim/View/ViewUiTouchStubs.cs + Generated/_IfaceImpl.g.cs HEADLESS-FIX). The +// virtual mutating-cosmetic methods are overridden to no-ops here so they never deref the null +// _attackTargetSelectInfo._attackPairsCardIsInvolvedIn animation queue. + +using Wizard.Battle.View.Vfx; + +namespace Wizard.Battle.View +{ + /// A no-op seeded as the headless player view's + /// AttackSelectControl. Overrides only the cosmetic attack-pair animation entry points the receive + /// ATTACK path invokes; all authoritative damage/death resolution stays in ActionProcessor.Attack. + public sealed class HeadlessAttackSelectControl : AttackSelectControl + { + public static readonly HeadlessAttackSelectControl Instance = new(); + + // The receive path enqueues an attack pair for the translate-up animation. No UI headless, so + // skip it entirely (the base would deref the card view's null _attackTargetSelectInfo queue). + public override void RegisterAttackPair(AttackPair attackPair) { } + + // Post-attack card reset is a position tween; no-op headless. + public override VfxBase ResetCardAfterAttack(IBattleCardView cardToReset) => NullVfx.GetInstance(); + + public override VfxBase ResetCardAfterAttackOnReplay() => NullVfx.GetInstance(); + + // Idle pingpong tween; no-op headless. + public override void StartCardIdling(IBattleCardView battleCardView) { } + } +} diff --git a/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs b/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs index c3da8ac..5790011 100644 --- a/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs +++ b/SVSim.BattleEngine/Shim/View/SettingsUiStubs.cs @@ -53,6 +53,11 @@ namespace Wizard.Battle.View // interface here with no-op members (the leaf PlayerClassBattleCardView inherits them). public abstract class ClassBattleCardViewBase : BattleCardView, IClassBattleCardView { + // HEADLESS-FIX (M-HC-4a): forward the BuildInfo to the BattleCardView base so a class (leader) + // view's CardInfo resolves to its backing card — the receive ATTACK path reads CardInfo.IsClass + // (true for a leader) via AttackSelectControl.IsCardTranslatable when an attack targets the leader. + protected ClassBattleCardViewBase() { } + protected ClassBattleCardViewBase(BuildInfo buildInfo) : base(buildInfo) { } public virtual Wizard.Battle.Player.ClassCharacter.IClassCharacter ClassCharacter => null; public virtual void StartOutFrame() { } public virtual void StartIntoFrame() { } @@ -60,7 +65,7 @@ namespace Wizard.Battle.View public virtual bool GetCurrentClipIsName(global::ClassCharaPrm.MotionType motionType) => false; public virtual void ClearSpineObject() { } } - public class NullBattleCardView : BattleCardView { public NullBattleCardView() { } public NullBattleCardView(BuildInfo buildInfo) { } public static void ReleaseSharedDummy() { } } + public class NullBattleCardView : BattleCardView { public NullBattleCardView() { } public NullBattleCardView(BuildInfo buildInfo) : base(buildInfo) { } public static void ReleaseSharedDummy() { } } // HEADLESS-FIX (M-HC-4a): chain BuildInfo so a null-view card's CardInfo still resolves // The decomp NullClassBattleCardView is `: NullBattleCardView, IClassBattleCardView, IBattleCardView`; // base-clause recovery kept only the base class. IBattleCardView is satisfied via the BattleCardView diff --git a/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs b/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs index b523867..d3ea9df 100644 --- a/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs +++ b/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs @@ -14,7 +14,19 @@ namespace Wizard.Battle.View // Parameterless ctor lets the no-op subclass hand stubs (ClassBattleCardViewBase, // NullBattleCardView) and any non-chaining stub satisfy their implicit base() call. public BattleCardView() { } - public BattleCardView(BuildInfo buildInfo) { } + public BattleCardView(BuildInfo buildInfo) { _buildInfo = buildInfo; } + + // HEADLESS-FIX (M-HC-4a): the receive ATTACK path reads BattleCardView.CardInfo (the backing + // card) and BattleCardView._inPlayFrameEffect on the resolve path (InPlayCardReflection / + // ActionProcessor.Attack). The interface getters in Generated/_IfaceImpl.g.cs surface these two + // fields. CardInfo comes from the stored BuildInfo (cardInfo == the card, IReadOnlyBattleCardInfo, + // so IsClass etc. are authentic); _inPlayFrameEffect is a non-null no-op frame-effect control + // whose HideFrameEffect/UpdateCanAttackEffect are empty (Generated/InPlayCardFrameEffectControl.g.cs). + internal BuildInfo _buildInfo; + internal IReadOnlyBattleCardInfo HeadlessCardInfo => _buildInfo?.cardInfo; + internal InPlayCardFrameEffectControl _headlessInPlayFrameEffect = + new InPlayCardFrameEffectControl(null, null, null); + // AttackTargetSelectInfo provided by Generated/BattleCardView_AttackTargetSelectInfo.g.cs public virtual UnityEngine.GameObject GameObject { get; protected set; } public HandCardFrameEffectControl HandFrameEffect { get; private set; } diff --git a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs index 854fc8a..2fe438c 100644 --- a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs +++ b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs @@ -52,6 +52,12 @@ internal static class EngineGlobalInit private const int PlayerCharaId = 1; private const int EnemyCharaId = 2; + /// The headless engine's "self" viewer id (seeded into Certification.viewer_id). Any + /// stable nonzero value works; it only has to be DISTINCT from the vid an attack/evolve stamps for a + /// target on the OTHER seat so the IsRecovery target parse resolves owners correctly. Exposed for the + /// node-native harness to build attack frames whose target vid matches this perspective. + internal const int ThisViewerId = 1001; + public static void EnsureInitialized() { if (_done) return; @@ -134,6 +140,22 @@ internal static class EngineGlobalInit if (string.IsNullOrEmpty(udidField.GetValue(null) as string)) udidField.SetValue(null, "headless-udid"); + // --- Cute.Certification.viewer_id ------------------------------------------------------ + // The IsRecovery target parse (NetworkBattleReceiver.CreateTargetList, isWatch branch) derives + // a target's owner from `vid != PlayerStaticData.UserViewerID`, where + // UserViewerID => Certification.ViewerId, whose getter LAZILY reads + // Toolbox.SavedataManager.GetInt("VIEWER_ID") when its backing field is 0 — and that savedata + // read throws headless. The exception is SWALLOWED by ConvertReceiveDataToMakeData's blanket + // catch, which (with the node's checkBreakData:false ingest) silently drops the parsed + // targetList, leaving an attack/evolve with an EMPTY target list -> the action throws on + // targetList[0]. Seed the backing field with a stable nonzero id so the getter short-circuits. + // It defines the engine's "player" perspective: a target vid == ThisViewerId resolves on + // BattlePlayer (engine seat A), vid != it on BattleEnemy (seat B). Only set when 0 (coexistence). + var viewerIdField = typeof(Certification).GetField("viewer_id", + BindingFlags.Static | BindingFlags.NonPublic)!; + if ((int)(viewerIdField.GetValue(null) ?? 0) == 0) + viewerIdField.SetValue(null, ThisViewerId); + _done = true; } } diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 37953ce..aa9069a 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -177,6 +177,30 @@ internal sealed class SessionBattleEngine public int InPlayCardId(bool playerSeat, int boardPos) => Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId; + /// The engine Index of the in-play follower at (0-based, + /// leader excluded — same convention as /). An ATTACK + /// frame addresses the attacker by this in-play Index (the wire playIdx), so a test reads it after + /// a follower resolves onto the board to build the attack (M-HC-4a). + public int InPlayCardIndex(bool playerSeat, int boardPos) => + Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index; + + /// The current life/health of the in-play follower at (0-based, + /// leader excluded). Reads (skill-resolved current health). Lets an + /// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade). + public int InPlayCardLife(bool playerSeat, int boardPos) => + Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life; + + /// The attack stat of the in-play follower at (skill-resolved + /// ). The damage it deals when it attacks. + public int InPlayCardAtk(bool playerSeat, int boardPos) => + Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk; + + /// True when the in-play follower at can still attack this turn + /// (). After it attacks (consuming its single attack) this reads + /// false — the "attacker is spent" assertion (M-HC-4a). + public bool InPlayCardAttackable(bool playerSeat, int boardPos) => + Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable; + /// The engine-RESOLVED play-time cost of the card whose engine Index == /// on (M-HC-3a). This is the discounted cost the play actually paid — /// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index c469c34..4be9a1d 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -318,6 +318,115 @@ public class HeadlessConductorTests "the seated card must be the wire cardId W, overriding the seeded Z identity at that idx"); } + // === M-HC-4a: attack resolves headless ======================================================= + + [Test] + public void Attack_on_enemy_leader_resolves_on_engine_state_headless() + { + // Seat A plays a vanilla follower on turn 1, then on its NEXT turn (past summoning sickness) + // attacks seat B's leader. Assert seat B's leader life drops by the follower's attack (1) and the + // attacker is spent. Driven entirely through the receive conductor (Push -> engine.Receive). + // + // Uniform vanilla deck so the card dealt at engine Index 1 is unambiguously the 1/2 vanilla. + var deck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList(); + using var harness = NodeNativeBattleHarness.Create(seatADeck: deck); + + // --- mulligan + open seat A turn 1 ------------------------------------------------------------ + Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); + Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, + Is.True, "turn1 TurnStart"); + + // Play the vanilla (engine Index 1, cost 1) onto seat A's board. + Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, + Is.True, "turn1 vanilla play"); + Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A follower on board after play"); + + // The just-played follower has summoning sickness this turn (can't attack yet). + Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False, + "a follower has summoning sickness the turn it is played"); + + int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0); + int attackerAtk = harness.InPlayCardAtk(playerSeat: true, boardPos: 0); + Assert.That(attackerAtk, Is.EqualTo(1), "the vanilla follower's attack stat is 1"); + + // --- advance to seat A's NEXT turn (turn 3) so the follower is past summoning sickness --------- + Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)"); + Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnEnd (B)"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnStart (A)"); + + Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True, + "the follower can attack on seat A's next turn (summoning sickness cleared)"); + + int leaderLifeBefore = harness.LeaderLife(playerSeat: false); + Assert.That(leaderLifeBefore, Is.EqualTo(20), "seat B leader untouched before the attack"); + + // --- the attack: seat A follower -> seat B leader (Index 0, on the enemy seat) ---------------- + var attack = harness.Push( + NetworkBattleUri.PlayActions, + NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx: 0, targetOnEnemySeat: true), + isPlayerSeat: true); + + Assert.That(attack.Accepted, Is.True, $"attack rejected: {attack.RejectReason}"); + Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(leaderLifeBefore - attackerAtk), + "seat B leader life must drop by the attacker's attack stat"); + Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False, + "the attacker is spent after attacking (can't attack again this turn)"); + } + + [Test] + public void Follower_vs_follower_attack_is_a_lethal_trade_headless() + { + // Seat A plays a 1/1 vanilla; seat B reveals a 1/1 vanilla (M-HC-2 reveal pattern). On seat A's + // next turn the follower attacks seat B's follower. Each deals 1 to a 1-life body -> a lethal + // trade: both followers' life drops and both leave the board. + var oneOne = NodeNativeBattleHarness.VanillaOneOneFollowerId; + var seatADeck = Enumerable.Repeat(oneOne, 30).ToList(); + var seatBDeck = Enumerable.Repeat(oneOne, 30).ToList(); + using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck, seatBDeck: seatBDeck); + + // --- mulligan + seat A turn 1: play the 1/1 ------------------------------------------------- + Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); + Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); + Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1"); + Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board"); + int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0); + + // --- seat B turn 2: reveal a 1/1 onto seat B's board ------------------------------------------ + Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)"); + Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "seat B board empty before reveal"); + Assert.That(harness.Push(NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: oneOne), isPlayerSeat: false).Accepted, + Is.True, "seat B reveal-play 1/1"); + Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "seat B 1/1 on board after reveal"); + int targetIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0); + + // --- back to seat A (turn 3): the 1/1 is past summoning sickness ------------------------------ + Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnEnd (B)"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnStart (A)"); + Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True, "attacker past summoning sickness"); + + Assert.That(harness.InPlayCardLife(playerSeat: true, boardPos: 0), Is.EqualTo(1), "attacker 1/1 full life before trade"); + Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 0), Is.EqualTo(1), "target 1/1 full life before trade"); + + // --- attack follower -> follower (target on enemy seat B) ------------------------------------ + var attack = harness.Push( + NetworkBattleUri.PlayActions, + NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx, targetOnEnemySeat: true), + isPlayerSeat: true); + + Assert.That(attack.Accepted, Is.True, $"follower trade rejected: {attack.RejectReason}"); + // 1/1 vs 1/1: each takes 1 -> both at 0 life -> both die and leave the board (lethal trade). + Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(0), "attacker 1/1 died in the trade"); + Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "target 1/1 died in the trade"); + Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), + "neither leader takes damage in a follower-vs-follower trade"); + } + [Test] public void Deal_seats_three_card_hand_headless() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 18ee326..b489557 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -63,6 +63,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// (Task-4 review nit promoted in M-HC-3). public const long AltVanillaFollowerId = 101211120; + /// A truly skill-less cost-1 vanilla follower with attack >= life (a 1/1), so a mutual + /// follower-vs-follower attack is a LETHAL trade (each deals 1, each has 1 life → both die). The + /// proven vanillas / are 1/2, so they + /// survive a single trade — this id is the one that exercises the death/removal arm of an attack + /// (M-HC-4a follower trade). Present + creatable in cards.json (no skill, char_type 1, cost 1, 1/1). + public const long VanillaOneOneFollowerId = 900011080; + public BattleSessionState State { get; } public StubParticipant SeatA { get; } public StubParticipant SeatB { get; } @@ -151,6 +158,19 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// (M-HC-2). public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos); + /// The engine Index of the in-play follower at — the wire + /// playIdx an ATTACK frame carries to address that follower as the attacker (M-HC-4a). + public int InPlayCardIndex(bool playerSeat, int boardPos) => Engine.InPlayCardIndex(playerSeat, boardPos); + + /// The current life/health of the in-play follower at . + public int InPlayCardLife(bool playerSeat, int boardPos) => Engine.InPlayCardLife(playerSeat, boardPos); + + /// The attack stat of the in-play follower at . + public int InPlayCardAtk(bool playerSeat, int boardPos) => Engine.InPlayCardAtk(playerSeat, boardPos); + + /// True while the in-play follower at can still attack this turn. + public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos); + /// Build an envelope for and ingest it into the engine for the /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + /// SessionBattleEngine.Receive. @@ -164,6 +184,51 @@ internal sealed class NodeNativeBattleHarness : IDisposable return Engine.Receive(env, isPlayerSeat); } + /// The engine's NetworkBattleDefine.PlayActionType.ATTACK opcode — confirmed + /// = 10 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (NOT 31, which is + /// PLAY_HAND_SELECT). The receiver maps the wire type int straight to the enum + /// (NetworkBattleReceiver.cs:1093). + public const int AttackOpcode = 10; + + /// The engine's "self" viewer id (== Certification.viewer_id seeded by EngineGlobalInit). + /// The IsRecovery target parse derives a target's owner from vid != PlayerStaticData.UserViewerID + /// (== this value) — NOT from the isSelf key (that key is only read on the live, non-recovery + /// parse). So a target vid == this resolves on BattlePlayer (engine seat A); vid != this on BattleEnemy + /// (seat B). + private const long SelfSeatVid = EngineGlobalInit.ThisViewerId; + + /// A viewer id distinct from , stamped when the target sits on the + /// engine's ENEMY seat (so the recovery parse marks it isSelf=true → BattleEnemy). + private const long EnemySeatVid = EngineGlobalInit.ThisViewerId + 1; + + /// Build a PlayActions ATTACK frame. is the attacker's in-play + /// engine Index (the wire playIdx); the target is described in targetList as + /// {targetIdx, vid, selectSkillIndex}. + /// The dispatch reads (_isPlayer ? PlayerTargetDataList : OpponentTargetDataList) + /// (WatchOperationCollection.InPlayActionOperation), and the targetList key populates the seat's + /// list matching the ingest's isPlayer — so a seat-A (isPlayer:true) attack correctly fills + /// PlayerTargetDataList. The target's OWNER is then resolved by + /// NetworkBattleGenericTool.LookForActionDataToTargetCard with fixed-seat semantics: + /// isSelf == falseBattlePlayer (engine seat A); isSelf == trueBattleEnemy + /// (engine seat B). Under IsRecovery, isSelf is computed from vid (see + /// ), so selects the vid stamp. + /// For a seat-A attack on seat B's leader: targetIdx = 0 (the leader/Class card is Index 0) + /// and targetOnEnemySeat = true. + public static Dictionary AttackBody(int attackerIdx, int targetIdx, bool targetOnEnemySeat) => new() + { + ["playIdx"] = attackerIdx, + ["type"] = AttackOpcode, + ["targetList"] = new List + { + new Dictionary + { + ["targetIdx"] = (long)targetIdx, + ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["selectSkillIndex"] = new List(), + }, + }, + }; + public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ } /// Minimal test-only exposing only the