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