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:
gamer147
2026-06-07 07:44:53 -04:00
parent 97e4664cc4
commit 25751462f4
3 changed files with 205 additions and 27 deletions

View File

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