From 62251482e46f0bde1416f94ec91e1bdc5633dc66 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 07:59:46 -0400 Subject: [PATCH] feat(battle-node): cross-side gift + Echo-frame token mining Close the two generated-token gaps that desynced PvP live test #3 (the Forestcraft Fairy), both sourced from the 2026-06-03 decomp-validation table. - MineAddOps now returns (idx, cardId, isSelf) and no longer drops isSelf:0. isSelf is the sender's perspective tag on CardObj.IsPlayer (RegisterToken.cs:22) and a card has one CardObj.Index, so an isSelf:0 add is the opponent's card. - New shared BattleSessionState.RecordTokensFrom routes isSelf:1 -> sender, isSelf:0 -> opponent (the gift lives in the recipient's map, consulted when they play it). PlayActionsHandler delegates to it. - EchoHandler now mines via the same helper but still returns no routes. An Echo's orderList carries the same add-op shape as a send (MakeEchoData -> MakeCommonSendAndEchoCardData), so MineAddOps applies verbatim; mining != relaying. Choice/copy/private-group adds stay skipped (no concrete cardId). Full solution 963/963 green. Co-Authored-By: Claude Opus 4.8 --- .../Sessions/Dispatch/BattleSessionState.cs | 15 ++++ .../Sessions/Dispatch/Handlers/EchoHandler.cs | 18 ++++- .../Dispatch/Handlers/PlayActionsHandler.cs | 8 +- .../Sessions/Dispatch/KnownListBuilder.cs | 28 +++---- .../Integration/CaptureConformanceTests.cs | 2 +- .../Sessions/BattleSessionDispatchTests.cs | 74 +++++++++++++++++++ .../Sessions/KnownListBuilderTests.cs | 13 ++-- 7 files changed, 133 insertions(+), 25 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index 5654059..40723cc 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -41,4 +41,19 @@ internal sealed class BattleSessionState GetOrSeedDeckMap(side); // ensure the per-side map exists (deck-seeded) IdxToCardId[side][idx] = cardId; // overwrite-on-conflict: latest identity wins } + + /// Mine generated-token identities from a sender's orderList add ops and + /// record each into the correct side's map. isSelf:1 → the sender's own token (); isSelf:0 → a cross-side gift living at that idx in the OPPONENT's index + /// space () — isSelf is the sender's perspective tag on + /// CardObj.IsPlayer (RegisterToken.cs:22), and a card has a single CardObj.Index, so + /// the gifted idx is the same slot in the recipient's own map (the one consulted when the recipient + /// later plays it). Shared by PlayActionsHandler and EchoHandler — an Echo's orderList + /// carries the same add-op shape (SendCardDataMaker.MakeEchoData), so both mine identically; + /// Echo is mined but never relayed. + public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList) + { + foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList)) + RecordToken(isSelf == 1 ? from : other, idx, cardId); + } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs index 27dfbb5..2d1d655 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs @@ -1,8 +1,22 @@ +using SVSim.BattleNode.Protocol; + namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; /// Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the -/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm. +/// node never relays it (bullet-2 audit — relaying would risk an echo->echo storm). It IS mined, +/// though: an Echo's orderList carries the same add-op shape as PlayActions +/// (SendCardDataMaker.MakeEchoData -> MakeCommonSendAndEchoCardData), so it can hold a token's real +/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it into the +/// right side's idx->cardId map and still return no routes (mining != relaying). internal sealed class EchoHandler : IFrameHandler { - public IReadOnlyList Handle(FrameDispatchContext ctx) => Array.Empty(); + public IReadOnlyList Handle(FrameDispatchContext ctx) + { + if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + { + var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList"); + ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); + } + return Array.Empty(); + } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index ba9184b..760089f 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -21,10 +21,10 @@ internal sealed class PlayActionsHandler : IFrameHandler var orderList = entries.GetValueOrDefault("orderList"); - // Mine generated-token identities from this frame's add ops into the sender's idx->cardId - // map, so a token played in a LATER frame resolves its cardId (bullet-3 audit F1). - foreach (var (idx, cardId) in KnownListBuilder.MineAddOps(orderList)) - ctx.State.RecordToken(ctx.From, idx, cardId); + // Mine generated-token identities from this frame's add ops into the right side's idx->cardId + // map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER + // frame resolves its cardId — by whichever side ends up playing it (bullet-3 audit F1). + ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList); diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 3da25b7..646409e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -42,17 +42,19 @@ internal static class KnownListBuilder return null; } - /// Mine generated-token identities from the sender's add ops: yields - /// (idx, cardId) for every idx in each {add:{idx:[...], isSelf:1, card:{cardId}}} - /// op. Skips isSelf:0 adds (cross-side gifts — belong in the other side's map, deferred) - /// and any add whose card has no concrete cardId — choice tokens - /// (card:{candidates}, RegisterChoiceAdd), copy tokens (card:{baseIdx}, - /// RegisterCopyToken), and private-group adds (string idx) — all deferred and all - /// caught by the cardId-key / idx-is-list guards. This is the only place a - /// freshly-generated card's identity exists on the wire (bullet-3 audit F1; producing code - /// RegisterToken/RegisterActionBase) — the played-card op itself never carries a - /// cardId. - public static IEnumerable<(int Idx, long CardId)> MineAddOps(object? orderList) + /// Mine generated-token identities from a sender's add ops: yields + /// (idx, cardId, isSelf) for every idx in each {add:{idx:[...], isSelf, card:{cardId}}} + /// op. isSelf is surfaced verbatim (the sender's perspective tag on CardObj.IsPlayer, + /// RegisterToken.cs:22) so the caller can route the identity into the correct side's map — + /// isSelf:1 = the sender's own token, isSelf:0 = a cross-side gift living at this idx + /// in the OPPONENT's index space (). Skips any add + /// whose card has no concrete cardId — choice tokens (card:{candidates}, + /// RegisterChoiceAdd), copy tokens (card:{baseIdx}, RegisterCopyToken), and + /// private-group adds (string idx) — all deferred and all caught by the cardId-key / + /// idx-is-list guards. This is the only place a freshly-generated card's identity exists on + /// the wire (bullet-3 audit F1; producing code RegisterToken/RegisterActionBase) — + /// the played-card op itself never carries a cardId. + public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList) { if (orderList is not IEnumerable ops) yield break; foreach (var op in ops) @@ -61,7 +63,7 @@ internal static class KnownListBuilder if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary add) continue; add.TryGetValue("isSelf", out var isSelfRaw); - if (AsLong(isSelfRaw) != 1) continue; // own tokens only; cross-side gifts deferred + var isSelf = (int)AsLong(isSelfRaw); if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue; if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet @@ -69,7 +71,7 @@ internal static class KnownListBuilder if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) - yield return ((int)AsLong(i), cardId); + yield return ((int)AsLong(i), cardId, isSelf); } } diff --git a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs index 20367ab..8b18cdf 100644 --- a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -322,7 +322,7 @@ public class CaptureConformanceTests ["card"] = new Dictionary { ["cardId"] = 900811111L } } }, }; var map = new Dictionary(); - foreach (var (idx, cardId) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineAddOps(generatingOrderList)) + foreach (var (idx, cardId, _) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineAddOps(generatingOrderList)) map[idx] = cardId; var playOrderList = new List diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index a9e1ab8..04ec16c 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -311,6 +311,45 @@ public class BattleSessionDispatchTests Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); } + [Test] + public void Pvp_PlayActions_cross_side_gift_is_revealed_when_the_opponent_plays_it() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // A plays a card whose effect GIFTS B a token at idx 31 (isSelf:0 — from A's perspective the + // card lives in the OPPONENT's index space; RegisterToken.cs:22 sets isSelf = CardObj.IsPlayer). + // The node must record it into B's map, not A's. + var gift = new Dictionary + { + ["playIdx"] = 3L, + ["type"] = 30L, + ["orderList"] = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 0L, + ["card"] = new Dictionary { ["cardId"] = 900111010L } } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gift)); + + // Later, B plays the gifted token idx 31 (hand 10 -> field 20). A must see its real identity. + var play = MoveOrderList(idx: 31, from: 10, to: 20); + play["playIdx"] = 31L; + play["type"] = 30L; + var routes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(pb.KnownList, Is.Not.Null, "the gifted token's identity was recorded into B's map"); + Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "mined cross-side gift cardId"); + Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); + } + [Test] public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops() { @@ -335,6 +374,41 @@ public class BattleSessionDispatchTests Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm."); } + [Test] + public void Pvp_Echo_mines_token_identity_for_a_later_reveal() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // B's Echo carries its own (isSelf:1) view of a token it received at idx 31. An Echo's + // orderList carries the SAME add-op shape as PlayActions (SendCardDataMaker.MakeEchoData -> + // MakeCommonSendAndEchoCardData), so the node MINES it for the identity — but still never + // relays the Echo (no inbound client handler). Mining != relaying. + var echo = new Dictionary + { + ["orderList"] = new List + { + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["cardId"] = 900111010L } } }, + }, + }; + var echoRoutes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.Echo, echo)); + Assert.That(echoRoutes, Is.Empty, "Echo is mined, not relayed."); + + // B plays the token idx 31 (hand 10 -> field 20); A must now see its real identity. + var play = MoveOrderList(idx: 31, from: 10, to: 20); + play["playIdx"] = 31L; + play["type"] = 30L; + var routes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.KnownList, Is.Not.Null, "Echo-mined token identity surfaces on play"); + Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "mined-from-Echo token cardId"); + } + [Test] public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B() { diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index 8f08a83..708fd83 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -129,15 +129,18 @@ public class KnownListBuilderTests var orderList = new List { AddOp(new[] { 31L, 32L }, 900111010L) }; var mined = KnownListBuilder.MineAddOps(orderList).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900111010L) })); + Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900111010L, 1) })); } [Test] - public void MineAddOps_skips_add_ops_for_the_opponent_isSelf_0() + public void MineAddOps_yields_cross_side_gifts_with_isSelf_0() { - // A card given to the opponent (isSelf:0) belongs in the other side's map — deferred. + // A card gifted to the opponent (isSelf:0) is the opponent's card at this idx (isSelf is the + // sender's perspective tag on CardObj.IsPlayer — RegisterToken.cs:22). The extractor surfaces + // it; the caller routes it into the OTHER side's map. var orderList = new List { AddOp(new[] { 31L }, 900111010L, isSelf: 0) }; - Assert.That(KnownListBuilder.MineAddOps(orderList), Is.Empty); + Assert.That(KnownListBuilder.MineAddOps(orderList), + Is.EquivalentTo(new[] { (31, 900111010L, 0) })); } [Test] @@ -200,6 +203,6 @@ public class KnownListBuilderTests AddOp(new[] { 32L }, 900811090L), }; var mined = KnownListBuilder.MineAddOps(orderList).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900811090L) })); + Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) })); } }