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