feat(battlenode): attack resolves on engine state via view-untangle (M-HC-4a)

Drive ATTACK frames through the headless receive conductor and assert on engine
board state (node-native harness). Two cases: follower -> enemy leader (leader
life drops by atk, attacker spent) and a lethal follower-vs-follower trade (both
removed). ATTACK opcode confirmed = 10 (NetworkBattleDefine.PlayActionType).

Headless view-untangle (no Engine logic edits; drift clean):
- IBattlePlayerView.AttackSelectControl -> non-null HeadlessAttackSelectControl
  (no-op RegisterAttackPair/ResetCardAfterAttack); IsCardTranslatable left to base.
- IBattleCardView.CardInfo -> backing card via BuildInfo (so IsCardTranslatable
  reads authentic IsClass); class/null view ctors now chain : base(buildInfo).
- IBattleCardView._inPlayFrameEffect -> non-null no-op control.
- Seed Certification.viewer_id headless so the IsRecovery target parse
  (vid != UserViewerID) does not throw inside SavedataManager and silently drop
  the parsed targetList.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 22:48:26 -04:00
parent 0d7136787a
commit c5a511e4fe
11 changed files with 291 additions and 9 deletions

View File

@@ -27,7 +27,14 @@ public partial class BuildInfo
public Func<bool> _getIsAbleToAttack;
public Func<bool> _getIsUnableToAttackClass;
public Func<HandCardFrameEffectType> getHandCardFrameEffectType;
public BuildInfo(IReadOnlyBattleCardInfo cardInfo, BattlePlayerReadOnlyInfoPair playerInfoPair, GameObject gameObject, BattleCamera battleCamera, BackGroundBase backGround, IBattleResourceMgr resourceMgr, Func<bool> getIsTouchable, Func<bool> getIsMovable, Func<bool> getIsOnMove, Func<int, bool> getIsFixedUseEnable, Func<bool> getIsActionCard, Func<bool> getIsAbleToAttack, Func<bool> getIsUnableToAttackClass, Func<HandCardFrameEffectType> 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<bool> getIsTouchable, Func<bool> getIsMovable, Func<bool> getIsOnMove, Func<int, bool> getIsFixedUseEnable, Func<bool> getIsActionCard, Func<bool> getIsAbleToAttack, Func<bool> getIsUnableToAttackClass, Func<HandCardFrameEffectType> getHandCardFrameEffectType)
{
this.cardInfo = cardInfo;
this._playerInfoPair = playerInfoPair;
}
}
}
}

View File

@@ -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!;

View File

@@ -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!;

View File

@@ -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!; }

View File

@@ -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
{
/// <summary>A no-op <see cref="AttackSelectControl"/> 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.</summary>
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) { }
}
}

View File

@@ -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

View File

@@ -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; }

View File

@@ -52,6 +52,12 @@ internal static class EngineGlobalInit
private const int PlayerCharaId = 1;
private const int EnemyCharaId = 2;
/// <summary>The headless engine's "self" viewer id (seeded into <c>Certification.viewer_id</c>). 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.</summary>
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;
}
}

View File

@@ -177,6 +177,30 @@ internal sealed class SessionBattleEngine
public int InPlayCardId(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId;
/// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded — same convention as <see cref="BoardCount"/>/<see cref="InPlayCardId"/>). An ATTACK
/// frame addresses the attacker by this in-play Index (the wire <c>playIdx</c>), so a test reads it after
/// a follower resolves onto the board to build the attack (M-HC-4a).</summary>
public int InPlayCardIndex(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index;
/// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded). Reads <see cref="BattleCardBase.Life"/> (skill-resolved current health). Lets an
/// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade).</summary>
public int InPlayCardLife(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life;
/// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/> (skill-resolved
/// <see cref="BattleCardBase.Atk"/>). The damage it deals when it attacks.</summary>
public int InPlayCardAtk(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk;
/// <summary>True when the in-play follower at <paramref name="boardPos"/> can still attack this turn
/// (<see cref="BattleCardBase.Attackable"/>). After it attacks (consuming its single attack) this reads
/// false — the "attacker is spent" assertion (M-HC-4a).</summary>
public bool InPlayCardAttackable(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable;
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
/// on <paramref name="playerSeat"/> (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

View File

@@ -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()
{

View File

@@ -63,6 +63,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// (Task-4 review nit promoted in M-HC-3).</summary>
public const long AltVanillaFollowerId = 101211120;
/// <summary>A truly skill-less cost-1 vanilla follower with attack &gt;= 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 <see cref="VanillaFollowerId"/>/<see cref="AltVanillaFollowerId"/> 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).</summary>
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).</summary>
public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos);
/// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> — the wire
/// <c>playIdx</c> an ATTACK frame carries to address that follower as the attacker (M-HC-4a).</summary>
public int InPlayCardIndex(bool playerSeat, int boardPos) => Engine.InPlayCardIndex(playerSeat, boardPos);
/// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/>.</summary>
public int InPlayCardLife(bool playerSeat, int boardPos) => Engine.InPlayCardLife(playerSeat, boardPos);
/// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/>.</summary>
public int InPlayCardAtk(bool playerSeat, int boardPos) => Engine.InPlayCardAtk(playerSeat, boardPos);
/// <summary>True while the in-play follower at <paramref name="boardPos"/> can still attack this turn.</summary>
public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos);
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
/// <c>SessionBattleEngine.Receive</c>.</summary>
@@ -164,6 +184,51 @@ internal sealed class NodeNativeBattleHarness : IDisposable
return Engine.Receive(env, isPlayerSeat);
}
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.ATTACK</c> opcode — confirmed
/// <c>= 10</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (NOT 31, which is
/// PLAY_HAND_SELECT). The receiver maps the wire <c>type</c> int straight to the enum
/// (NetworkBattleReceiver.cs:1093).</summary>
public const int AttackOpcode = 10;
/// <summary>The engine's "self" viewer id (== <c>Certification.viewer_id</c> seeded by EngineGlobalInit).
/// The IsRecovery target parse derives a target's owner from <c>vid != PlayerStaticData.UserViewerID</c>
/// (== this value) — NOT from the <c>isSelf</c> 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).</summary>
private const long SelfSeatVid = EngineGlobalInit.ThisViewerId;
/// <summary>A viewer id distinct from <see cref="SelfSeatVid"/>, stamped when the target sits on the
/// engine's ENEMY seat (so the recovery parse marks it isSelf=true → BattleEnemy).</summary>
private const long EnemySeatVid = EngineGlobalInit.ThisViewerId + 1;
/// <summary>Build a PlayActions ATTACK frame. <paramref name="attackerIdx"/> is the attacker's in-play
/// engine <c>Index</c> (the wire <c>playIdx</c>); the target is described in <c>targetList</c> as
/// <c>{targetIdx, vid, selectSkillIndex}</c>.
/// <para>The dispatch reads <c>(_isPlayer ? PlayerTargetDataList : OpponentTargetDataList)</c>
/// (WatchOperationCollection.InPlayActionOperation), and the <c>targetList</c> key populates the seat's
/// list matching the ingest's <c>isPlayer</c> — so a seat-A (<c>isPlayer:true</c>) attack correctly fills
/// <c>PlayerTargetDataList</c>. The target's OWNER is then resolved by
/// <c>NetworkBattleGenericTool.LookForActionDataToTargetCard</c> with fixed-seat semantics:
/// <c>isSelf == false</c> → <c>BattlePlayer</c> (engine seat A); <c>isSelf == true</c> → <c>BattleEnemy</c>
/// (engine seat B). Under IsRecovery, <c>isSelf</c> is computed from <c>vid</c> (see
/// <see cref="EnemySeatVid"/>), so <paramref name="targetOnEnemySeat"/> selects the vid stamp.</para>
/// <para>For a seat-A attack on seat B's leader: <c>targetIdx = 0</c> (the leader/Class card is Index 0)
/// and <c>targetOnEnemySeat = true</c>.</para></summary>
public static Dictionary<string, object?> AttackBody(int attackerIdx, int targetIdx, bool targetOnEnemySeat) => new()
{
["playIdx"] = attackerIdx,
["type"] = AttackOpcode,
["targetList"] = new List<object?>
{
new Dictionary<string, object?>
{
["targetIdx"] = (long)targetIdx,
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
["selectSkillIndex"] = new List<object?>(),
},
},
};
public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ }
/// <summary>Minimal test-only <see cref="IBattleParticipant"/> exposing only the