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

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