From 25751462f4e3a2ebf1310ab4d73a8bb35225825b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 7 Jun 2026 07:44:53 -0400 Subject: [PATCH] fix(battlenode): translate live isSelf target frames to engine vid shape on ingest (live PvP fidelity) Co-Authored-By: Claude Opus 4.8 --- .../Sessions/Engine/SessionBattleEngine.cs | 65 +++++++++++ .../Integration/HeadlessConductorTests.cs | 104 ++++++++++++++++++ .../Integration/NodeNativeBattleHarness.cs | 63 ++++++----- 3 files changed, 205 insertions(+), 27 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index ab9042b..e1dcde4 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -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 dict, bool isPlayerSeat) + { + if (!dict.TryGetValue(TargetListKey, out var raw) || raw is not List entries) + return; + + foreach (var e in entries) + { + if (e is not Dictionary 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, diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index f8635ba..7e3a79a 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index cbac8a5..5d928a3 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -305,28 +305,35 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// (NetworkBattleReceiver.cs:1093). public const int AttackOpcode = 10; - /// The engine's "self" viewer id (== Certification.viewer_id seeded by EngineGlobalInit). - /// The IsRecovery target parse derives a target's owner from vid != PlayerStaticData.UserViewerID - /// (== this value) — NOT from the isSelf 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). - private const long SelfSeatVid = EngineGlobalInit.ThisViewerId; + // NOTE (live-fidelity migration): the target-builders below emit the REAL client wire shape — + // a sender-relative isSelf flag on each targetList entry — NOT the engine's internal + // vid stamp. Real client-sent attack/evolve/targeted-play frames carry + // {targetIdx, isSelf, selectSkillIndex} (verified in the client-send captures, e.g. + // data_dumps/captures/battle_test/battle-traffic_cl1.ndjson); the previous vid 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 vid != PlayerStaticData.UserViewerID, NetworkBattleReceiver.cs:2186), + // so the harness drives the live contract end-to-end. + // + // isSelf is relative to the FRAME's SENDER: isSelf:1 = the target sits on the sender's own + // seat; isSelf:0 = it sits on the OTHER seat. The builders take + // (stable signature) and map it to isSelf:0 (true) / isSelf:1 (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; - /// A viewer id distinct from , stamped when the target sits on the - /// engine's ENEMY seat (so the recovery parse marks it isSelf=true → BattleEnemy). - private const long EnemySeatVid = EngineGlobalInit.ThisViewerId + 1; - - /// Build a PlayActions ATTACK frame. is the attacker's in-play - /// engine Index (the wire playIdx); the target is described in targetList as - /// {targetIdx, vid, selectSkillIndex}. + /// Build a PlayActions ATTACK frame in the REAL client wire shape. + /// is the attacker's in-play engine Index (the wire playIdx); the target is described in + /// targetList as {targetIdx, isSelf, selectSkillIndex} — the sender-relative isSelf + /// flag a live client actually sends (see ). /// The dispatch reads (_isPlayer ? PlayerTargetDataList : OpponentTargetDataList) /// (WatchOperationCollection.InPlayActionOperation), and the targetList key populates the seat's /// list matching the ingest's isPlayer — so a seat-A (isPlayer:true) attack correctly fills - /// PlayerTargetDataList. The target's OWNER is then resolved by + /// PlayerTargetDataList. The target's OWNER is resolved by /// NetworkBattleGenericTool.LookForActionDataToTargetCard with fixed-seat semantics: - /// isSelf == falseBattlePlayer (engine seat A); isSelf == trueBattleEnemy - /// (engine seat B). Under IsRecovery, isSelf is computed from vid (see - /// ), so selects the vid stamp. + /// the engine's IsRecovery parse derives owner from a vid stamp, which + /// SessionBattleEngine.TranslateTargetOwners writes on ingest from this isSelf flag — + /// so drives the absolute target seat through the live contract. /// For a seat-A attack on seat B's leader: targetIdx = 0 (the leader/Class card is Index 0) /// and targetOnEnemySeat = true. public static Dictionary AttackBody(int attackerIdx, int targetIdx, bool targetOnEnemySeat) => new() @@ -338,7 +345,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable new Dictionary { ["targetIdx"] = (long)targetIdx, - ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, }, @@ -355,13 +362,14 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// Build a PlayActions PLAY_HAND_SELECT (targeted hand-play) frame. /// is the played hand card's engine Index (the wire playIdx); the single target is - /// described in targetList in the SAME {targetIdx, vid, selectSkillIndex} shape as + /// described in targetList in the SAME real {targetIdx, isSelf, selectSkillIndex} shape as /// / (the receive parse reads it identically — /// CreateTargetList in NetworkBattleReceiver.cs:2164 — into the seat's TargetDataList, and under - /// IsRecovery resolves the target's owner from vid, not an isSelf key). + /// IsRecovery resolves the target's owner from the vid that + /// SessionBattleEngine.TranslateTargetOwners derives from this isSelf flag on ingest). /// For a seat-A spell targeting an enemy follower: = the enemy - /// follower's in-play engine Index and = true (vid stamped - /// → isSelf=true → LookForActionDataToTargetCard resolves it on + /// follower's in-play engine Index and = true (isSelf:0 + /// → translated to the seat-B vid → LookForActionDataToTargetCard resolves it on /// BattleEnemy.ClassAndInPlayCardList). public static Dictionary TargetedPlayBody(int playIdx, int targetIdx, bool targetOnEnemySeat) => new() { @@ -372,7 +380,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable new Dictionary { ["targetIdx"] = (long)targetIdx, - ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, }, @@ -434,9 +442,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// Build a PlayActions EVOLUTION_SELECT frame: the follower at engine Index /// evolves and targets the card at . The target is - /// described in the SAME {targetIdx, vid, selectSkillIndex} shape as - /// (the dispatch resolves the target's owner from vid under IsRecovery, not from an isSelf key); - /// selects the vid stamp. + /// described in the SAME real {targetIdx, isSelf, selectSkillIndex} shape as + /// (the dispatch resolves the target's owner from the vid that + /// SessionBattleEngine.TranslateTargetOwners derives from this isSelf on ingest); + /// selects the isSelf flag. public static Dictionary EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new() { ["playIdx"] = cardIdx, @@ -446,7 +455,7 @@ internal sealed class NodeNativeBattleHarness : IDisposable new Dictionary { ["targetIdx"] = (long)targetIdx, - ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, },