fix(battlenode): translate live isSelf target frames to engine vid shape on ingest (live PvP fidelity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,7 @@ internal sealed class SessionBattleEngine
|
||||
throw new InvalidOperationException("Receive before Setup.");
|
||||
|
||||
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
|
||||
TranslateTargetOwners(dict, isPlayerSeat);
|
||||
var uri = MapUri(env.Uri);
|
||||
|
||||
try
|
||||
@@ -155,6 +156,70 @@ internal sealed class SessionBattleEngine
|
||||
}
|
||||
}
|
||||
|
||||
// --- live isSelf -> engine-vid target-owner translation (live PvP ingest fidelity) -------------
|
||||
//
|
||||
// THE GAP this closes: real clients send each targetList entry as {targetIdx, isSelf, selectSkillIndex}
|
||||
// (verified in client-send captures, e.g. data_dumps/captures/battle_test/battle-traffic_cl1.ndjson),
|
||||
// where `isSelf` is the SENDER's perspective flag (isSelf:1 = target on the sender's own seat;
|
||||
// isSelf:0 = target on the OTHER seat). But the engine receive path the node drives is IsRecovery, and
|
||||
// its recovery targetList parse (NetworkBattleReceiver.CreateTargetList, isWatch:true branch,
|
||||
// NetworkBattleReceiver.cs:2180-2188) derives a target's owner from a `vid` stamp:
|
||||
// isSelf_engine = (vid != PlayerStaticData.UserViewerID) // UserViewerID == EngineGlobalInit.ThisViewerId
|
||||
// and the downstream resolver (NetworkBattleGenericTool.LookForActionDataToTargetCard:133) routes
|
||||
// isSelf_engine == false -> BattlePlayer (engine seat A); isSelf_engine == true -> BattleEnemy (seat B).
|
||||
// So the engine vid encodes the target's ABSOLUTE seat: seat A == ThisViewerId, seat B != it.
|
||||
//
|
||||
// Without a translation a real `isSelf` frame carries no `vid`, so the recovery parse leaves
|
||||
// isSelf_engine=false (vid defaults 0 != ThisViewerId would even read TRUE, but with no key it's the
|
||||
// default-0 TargetData) and the target mis-resolves -> a targeted attack/spell/evolution silently
|
||||
// misses. We translate on the ENGINE's OWN dict copy only (ToEngineDict re-boxed a fresh dict; the
|
||||
// node's relay/mining read the ORIGINAL env.Body, which KnownListBuilder/RecordTokensFrom consume as
|
||||
// `isSelf` and must keep), so the node-side isSelf bookkeeping is untouched.
|
||||
//
|
||||
// ONLY engine-vid field on the live targeted frames: `targetList[].vid`. The recovery parse reads `vid`
|
||||
// exclusively in the isWatch:true `targetList` branch (the ONLY `vid` read on the receiver,
|
||||
// NetworkBattleReceiver.cs:2182); `oppoTargetList` parses `isSelf` directly (isWatch:false) but the node
|
||||
// never sends it. Non-targeted frames (deal/play/turn/mulligan) carry no targetList and pass through
|
||||
// unchanged.
|
||||
//
|
||||
// The (isPlayerSeat, isSelf) -> vid mapping (oracle: the harness's known-good SelfSeatVid/EnemySeatVid):
|
||||
// target is on seat A <=> isPlayerSeat == (isSelf == 1) // sender-relative isSelf -> absolute seat
|
||||
// seat A -> ThisViewerId ; seat B -> ThisViewerId + 1
|
||||
private static void TranslateTargetOwners(Dictionary<string, object> dict, bool isPlayerSeat)
|
||||
{
|
||||
if (!dict.TryGetValue(TargetListKey, out var raw) || raw is not List<object> entries)
|
||||
return;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not Dictionary<string, object> entry) continue;
|
||||
// Tolerate a vid already present (idempotent): leave the engine shape as-is. The primary
|
||||
// contract is the real isSelf shape, but a frame that already carries vid resolves directly.
|
||||
if (entry.ContainsKey(VidKey)) continue;
|
||||
if (!entry.TryGetValue(IsSelfKey, out var isSelfRaw)) continue;
|
||||
|
||||
bool isSelf = ToInt(isSelfRaw) == 1;
|
||||
bool targetOnSeatA = isPlayerSeat == isSelf;
|
||||
entry[VidKey] = targetOnSeatA ? EngineGlobalInit.ThisViewerId : EngineGlobalInit.ThisViewerId + 1;
|
||||
// Drop isSelf on the ENGINE copy: the isWatch:true recovery parse reads vid, not isSelf, so the
|
||||
// key is dead weight on this copy. (The node's relay/mining copy is a different dict and keeps it.)
|
||||
entry.Remove(IsSelfKey);
|
||||
}
|
||||
}
|
||||
|
||||
private const string TargetListKey = "targetList";
|
||||
private const string VidKey = "vid";
|
||||
private const string IsSelfKey = "isSelf";
|
||||
|
||||
// The decoded wire value may be a boxed long/int/bool depending on the codec; normalize to int.
|
||||
private static int ToInt(object v) => v switch
|
||||
{
|
||||
bool b => b ? 1 : 0,
|
||||
long l => (int)l,
|
||||
int i => i,
|
||||
_ => Convert.ToInt32(v),
|
||||
};
|
||||
|
||||
// --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ----------------
|
||||
// Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads
|
||||
// (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList,
|
||||
|
||||
@@ -611,6 +611,110 @@ public class HeadlessConductorTests
|
||||
"the NON-targeted follower must be UNTOUCHED (full life) — proves the wire target was honored");
|
||||
}
|
||||
|
||||
// === isSelf->vid owner-mapping is DIRECTIONAL across BOTH sender perspectives =================
|
||||
|
||||
[Test]
|
||||
public void Attack_from_seat_B_on_seat_A_follower_resolves_isSelf_reversed()
|
||||
{
|
||||
// The reversed-perspective half of the live isSelf->vid translation: a frame sent BY SEAT B
|
||||
// (isPlayerSeat:false) targeting a SEAT A follower carries isSelf:0 (the target is NOT on the
|
||||
// sender's seat). TranslateTargetOwners must map (isPlayerSeat:false, isSelf:0) -> the seat-A
|
||||
// engine vid (ThisViewerId), so the attack resolves on seat A's follower — NOT seat B's own. The
|
||||
// seat-A-sender M-HC-4c test proves the forward direction; this proves the mapping isn't
|
||||
// accidentally symmetric (a translation that ignored isPlayerSeat would mis-route a seat-B frame).
|
||||
//
|
||||
// Driven via an ATTACK (no hand-identity dependency): seat A plays a 1/2 vanilla turn 1; seat B
|
||||
// reveals a 1/2 vanilla turn 2 and on turn 4 attacks seat A's follower. Both are 1/2 so each
|
||||
// survives the single trade and the life DROP (2 -> 1) is readable on the seat-A target.
|
||||
var vanilla = NodeNativeBattleHarness.VanillaFollowerId; // 1/2
|
||||
var seatADeck = Enumerable.Repeat(vanilla, 30).ToList();
|
||||
var seatBDeck = Enumerable.Repeat(vanilla, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck, seatBDeck: seatBDeck);
|
||||
|
||||
// seat A turn 1: play a 1/2 onto seat A's board.
|
||||
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 (A)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/2 (A)");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "one seat A follower on board");
|
||||
int targetIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
|
||||
int targetLifeBefore = harness.InPlayCardLife(playerSeat: true, boardPos: 0);
|
||||
Assert.That(targetLifeBefore, Is.EqualTo(2), "seat A 1/2 at full life before the attack");
|
||||
|
||||
// seat B turn 2: reveal a 1/2 onto seat B's board (so it exists; it gains summoning sickness).
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd (A)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: vanilla), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play 1/2");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "one seat B follower on board");
|
||||
int attackerIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0);
|
||||
|
||||
// advance to seat B's NEXT turn (turn 4) so seat B's follower 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.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnEnd (A)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn4 TurnStart (B)");
|
||||
Assert.That(harness.InPlayCardAttackable(playerSeat: false, boardPos: 0), Is.True, "seat B attacker past summoning sickness");
|
||||
|
||||
// isPlayerSeat:false (seat B sends), targetOnEnemySeat:true -> isSelf:0 -> the SEAT-A engine vid.
|
||||
var attack = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx, targetOnEnemySeat: true),
|
||||
isPlayerSeat: false);
|
||||
|
||||
Assert.That(attack.Accepted, Is.True, $"seat-B attack rejected: {attack.RejectReason}");
|
||||
// The attack resolved onto the SEAT A follower (the reversed-perspective owner mapping worked):
|
||||
// the 1/2 target took 1 -> life 1.
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: true, boardPos: 0), Is.EqualTo(targetLifeBefore - 1),
|
||||
"the seat-A target took the attack's damage (isPlayerSeat:false, isSelf:0 -> seat A vid)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Attack_with_wrong_owner_flag_does_not_hit_the_enemy_follower()
|
||||
{
|
||||
// Negative / wrong-owner discriminator: seat A attacks but the targetList flags the target as the
|
||||
// SENDER's OWN (targetOnEnemySeat:false -> isSelf:1 -> the seat-A engine vid), while pointing at the
|
||||
// index where the ENEMY (seat B) follower sits. The translation must route that to seat A, so seat
|
||||
// B's follower is NOT hit — proving the owner mapping is directional, not "hit whatever sits at the
|
||||
// idx". (Mirrors the M-HC-4c target-discriminating pattern, on the OWNER axis.)
|
||||
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);
|
||||
|
||||
// seat A turn 1: play a 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");
|
||||
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.Push(NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: oneOne), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play 1/1");
|
||||
int enemyIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0);
|
||||
int enemyLifeBefore = harness.InPlayCardLife(playerSeat: false, boardPos: 0);
|
||||
|
||||
// back to seat A (turn 3): attacker 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");
|
||||
|
||||
// WRONG owner: targetOnEnemySeat:false (isSelf:1) but pointing at the enemy follower's idx. The
|
||||
// attack resolves against seat A's own space, so seat B's follower is NOT damaged.
|
||||
harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx: enemyIdx, targetOnEnemySeat: false),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 0), Is.EqualTo(enemyLifeBefore),
|
||||
"the enemy follower must be UNTOUCHED when the attack flags the target as the sender's own (wrong owner)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Choice_play_resolves_chosen_branch_on_engine_state_headless()
|
||||
{
|
||||
|
||||
@@ -305,28 +305,35 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// (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;
|
||||
// NOTE (live-fidelity migration): the target-builders below emit the REAL client wire shape —
|
||||
// a sender-relative <c>isSelf</c> flag on each targetList entry — NOT the engine's internal
|
||||
// <c>vid</c> stamp. Real client-sent attack/evolve/targeted-play frames carry
|
||||
// <c>{targetIdx, isSelf, selectSkillIndex}</c> (verified in the client-send captures, e.g.
|
||||
// data_dumps/captures/battle_test/battle-traffic_cl1.ndjson); the previous <c>vid</c> shape was a
|
||||
// harness workaround that masked a missing ingest translation. SessionBattleEngine.Receive now
|
||||
// translates isSelf → the engine vid on the engine's OWN dict copy (the engine's IsRecovery target
|
||||
// parse derives owner from <c>vid != PlayerStaticData.UserViewerID</c>, NetworkBattleReceiver.cs:2186),
|
||||
// so the harness drives the live contract end-to-end.
|
||||
//
|
||||
// isSelf is relative to the FRAME's SENDER: <c>isSelf:1</c> = the target sits on the sender's own
|
||||
// seat; <c>isSelf:0</c> = it sits on the OTHER seat. The builders take <paramref name="targetOnEnemySeat"/>
|
||||
// (stable signature) and map it to <c>isSelf:0</c> (true) / <c>isSelf:1</c> (false), since every
|
||||
// builder is driven by seat A attacking/targeting seat B's card (targetOnEnemySeat:true) or its own
|
||||
// (false).
|
||||
private static long IsSelfFlag(bool targetOnEnemySeat) => targetOnEnemySeat ? 0 : 1;
|
||||
|
||||
/// <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>.
|
||||
/// <summary>Build a PlayActions ATTACK frame in the REAL client wire shape. <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, isSelf, selectSkillIndex}</c> — the sender-relative <c>isSelf</c>
|
||||
/// flag a live client actually sends (see <see cref="IsSelfFlag"/>).
|
||||
/// <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>PlayerTargetDataList</c>. The target's OWNER is 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>
|
||||
/// the engine's IsRecovery parse derives owner from a <c>vid</c> stamp, which
|
||||
/// <c>SessionBattleEngine.TranslateTargetOwners</c> writes on ingest from this <c>isSelf</c> flag —
|
||||
/// so <paramref name="targetOnEnemySeat"/> drives the absolute target seat through the live contract.</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()
|
||||
@@ -338,7 +345,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["targetIdx"] = (long)targetIdx,
|
||||
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
||||
["isSelf"] = IsSelfFlag(targetOnEnemySeat),
|
||||
["selectSkillIndex"] = new List<object?>(),
|
||||
},
|
||||
},
|
||||
@@ -355,13 +362,14 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
|
||||
/// <summary>Build a PlayActions PLAY_HAND_SELECT (targeted hand-play) frame. <paramref name="playIdx"/>
|
||||
/// is the played hand card's engine <c>Index</c> (the wire <c>playIdx</c>); the single target is
|
||||
/// described in <c>targetList</c> in the SAME <c>{targetIdx, vid, selectSkillIndex}</c> shape as
|
||||
/// described in <c>targetList</c> in the SAME real <c>{targetIdx, isSelf, selectSkillIndex}</c> shape as
|
||||
/// <see cref="AttackBody"/>/<see cref="EvolveSelectBody"/> (the receive parse reads it identically —
|
||||
/// <c>CreateTargetList</c> in NetworkBattleReceiver.cs:2164 — into the seat's TargetDataList, and under
|
||||
/// IsRecovery resolves the target's owner from <c>vid</c>, not an isSelf key).
|
||||
/// IsRecovery resolves the target's owner from the <c>vid</c> that
|
||||
/// <c>SessionBattleEngine.TranslateTargetOwners</c> derives from this <c>isSelf</c> flag on ingest).
|
||||
/// <para>For a seat-A spell targeting an enemy follower: <paramref name="targetIdx"/> = the enemy
|
||||
/// follower's in-play engine Index and <paramref name="targetOnEnemySeat"/> = <c>true</c> (vid stamped
|
||||
/// <see cref="EnemySeatVid"/> → isSelf=true → <c>LookForActionDataToTargetCard</c> resolves it on
|
||||
/// follower's in-play engine Index and <paramref name="targetOnEnemySeat"/> = <c>true</c> (<c>isSelf:0</c>
|
||||
/// → translated to the seat-B vid → <c>LookForActionDataToTargetCard</c> resolves it on
|
||||
/// <c>BattleEnemy.ClassAndInPlayCardList</c>).</para></summary>
|
||||
public static Dictionary<string, object?> TargetedPlayBody(int playIdx, int targetIdx, bool targetOnEnemySeat) => new()
|
||||
{
|
||||
@@ -372,7 +380,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["targetIdx"] = (long)targetIdx,
|
||||
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
||||
["isSelf"] = IsSelfFlag(targetOnEnemySeat),
|
||||
["selectSkillIndex"] = new List<object?>(),
|
||||
},
|
||||
},
|
||||
@@ -434,9 +442,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
|
||||
/// <summary>Build a PlayActions EVOLUTION_SELECT frame: the follower at engine <c>Index</c>
|
||||
/// <paramref name="cardIdx"/> evolves and targets the card at <paramref name="targetIdx"/>. The target is
|
||||
/// described in the SAME <c>{targetIdx, vid, selectSkillIndex}</c> shape as <see cref="AttackBody"/>
|
||||
/// (the dispatch resolves the target's owner from <c>vid</c> under IsRecovery, not from an isSelf key);
|
||||
/// <paramref name="targetOnEnemySeat"/> selects the vid stamp.</summary>
|
||||
/// described in the SAME real <c>{targetIdx, isSelf, selectSkillIndex}</c> shape as <see cref="AttackBody"/>
|
||||
/// (the dispatch resolves the target's owner from the <c>vid</c> that
|
||||
/// <c>SessionBattleEngine.TranslateTargetOwners</c> derives from this <c>isSelf</c> on ingest);
|
||||
/// <paramref name="targetOnEnemySeat"/> selects the isSelf flag.</summary>
|
||||
public static Dictionary<string, object?> EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new()
|
||||
{
|
||||
["playIdx"] = cardIdx,
|
||||
@@ -446,7 +455,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["targetIdx"] = (long)targetIdx,
|
||||
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
||||
["isSelf"] = IsSelfFlag(targetOnEnemySeat),
|
||||
["selectSkillIndex"] = new List<object?>(),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user