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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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!; }
|
||||
|
||||
38
SVSim.BattleEngine/Shim/View/HeadlessAttackSelectControl.cs
Normal file
38
SVSim.BattleEngine/Shim/View/HeadlessAttackSelectControl.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 >= 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
|
||||
|
||||
Reference in New Issue
Block a user