From b6edfbcf15debf646eb5b73bf743e2c9b4ef132c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 10:11:34 -0400 Subject: [PATCH] feat(battle-node): reveal copy tokens on play via baseIdx resolution PlayActionsHandler + EchoHandler now call RecordCopyTokensFrom (ordered after plain/choice mining) to resolve a copy add's baseIdx against the side's live idx->cardId map and record copyIdx->cardId. A copy played in a later (or same) frame synthesizes a knownList instead of degrading. Co-Authored-By: Claude Opus 4.8 --- .../Sessions/Dispatch/BattleSessionState.cs | 17 ++ .../Sessions/Dispatch/Handlers/EchoHandler.cs | 7 +- .../Dispatch/Handlers/PlayActionsHandler.cs | 12 +- .../Sessions/BattleSessionDispatchTests.cs | 171 ++++++++++++++++++ 4 files changed, 202 insertions(+), 5 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index 3c0d267..6442a0e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -67,4 +67,21 @@ internal sealed class BattleSessionState foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction)) RecordToken(isSelf == 1 ? from : other, idx, cardId); } + + /// Mine + record copy/clone-token identities () + /// into the correct side's map. A copy's source lives at baseIdx in the actor's own index + /// space, so the resolution side == the record side, both selected by the same isSelf routing + /// as . Passing the LIVE per-side maps (via + /// , not snapshots) lets a copy that references a plain/choice token + /// added earlier THIS frame resolve — provided this runs AFTER + /// / (the handler orders it last). + /// Seeding both maps up front matters because a copy-only frame (no concrete/choice add) would never + /// have hit yet, leaving the maps unseeded. + public void RecordCopyTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList) + { + var selfMap = GetOrSeedDeckMap(from); + var otherMap = GetOrSeedDeckMap(other); + foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap)) + 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 4117e8c..5005a04 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs @@ -6,8 +6,9 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; /// 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). +/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it (concrete +/// tokens and baseIdx copies) 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) @@ -16,6 +17,8 @@ internal sealed class EchoHandler : IFrameHandler { var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList"); ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); + // Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map. + ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList); // No RecordChoicePicksFrom here: choice picks ride keyAction.selectCard on the generating // SEND, not the receiver's Echo (Echo carries orderList only) — the pick is already // recorded by PlayActionsHandler. MineChoicePicks(orderList, null) would yield nothing. diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 53d55a3..92eba7e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -6,9 +6,10 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; /// PvP PlayActions translator. Synthesizes the opponent-facing knownList from the sender's /// idx->cardId map + the orderList move op, renames targetList -> oppoTargetList, drops orderList, /// and forwards a stripped keyAction for choice/Discover plays ({type,cardId}; selectCard dropped -/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens) or -/// keyAction.selectCard (choice picks) mined on earlier frames; an un-generated token idx still -/// degrades to {playIdx,type} (no knownList). Bot drop (no rule). +/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens), +/// keyAction.selectCard (choice picks), or a baseIdx copy resolved against the side's map — all mined +/// on earlier (or the same) frames; an un-generated token idx still degrades to {playIdx,type} +/// (no knownList). Bot drop (no rule). internal sealed class PlayActionsHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) @@ -32,6 +33,11 @@ internal sealed class PlayActionsHandler : IFrameHandler // choiceAdd carries candidates only). Record idx->chosenCardId now so the later play reveals it. ctx.State.RecordChoicePicksFrom(ctx.From, ctx.Other, orderList, keyAction); + // Copy/clone tokens: card:{baseIdx} points at a card in the actor's own index space; resolve it + // against that side's map and record copyIdx->cardId so the later play reveals it. Ordered after + // the plain/choice mining so a same-frame copy of a just-added token resolves against the live map. + ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList); + var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList); var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList")); diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 493af41..3700801 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -350,6 +350,177 @@ public class BattleSessionDispatchTests Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); } + [Test] + public void Pvp_PlayActions_reveals_copy_token_generated_in_an_earlier_frame() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // Frame 1: A plays deck card idx 3; its fanfare ADDS a concrete token idx 31 (cardId 900_111_010) + // to A's hand (limbo 50 -> hand 10). + var gen = 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"] = 1L, + ["card"] = new Dictionary { ["cardId"] = 900_111_010L } } }, + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen)); + + // Frame 2: A plays deck card idx 4; its effect COPIES the token at idx 31 into a new token idx 32 + // (card:{baseIdx:31}) in A's hand. + var copy = new Dictionary + { + ["playIdx"] = 4L, ["type"] = 30L, + ["orderList"] = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 4L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 32L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["baseIdx"] = 31L, ["isPremium"] = 0L } } }, + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 32L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, copy)); + + // Frame 3: A plays the copy token idx 32 from hand (10) to field (20). + var play = MoveOrderList(idx: 32, from: 10, to: 20); + play["playIdx"] = 32L; play["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.PlayIdx, Is.EqualTo(32)); + Assert.That(pb.KnownList, Is.Not.Null, "the copy's identity was resolved from baseIdx and remembered"); + Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(32)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "copy resolved to its source token's cardId"); + Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); + } + + [Test] + public void Pvp_PlayActions_copy_of_a_token_added_in_the_same_frame_resolves() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // One frame: A's play ADDS concrete token idx 40 (cardId 900_222_020), then COPIES it to idx 41 + // (card:{baseIdx:40}) — copy op AFTER the concrete add in the same orderList. The copy must + // resolve against the live map (copy mining runs after plain mining). + var frame = 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 { 40L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["cardId"] = 900_222_020L } } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 41L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["baseIdx"] = 40L, ["isPremium"] = 0L } } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, frame)); + + // Later: A plays the copy idx 41 (hand 10 -> field 20). Reveal proves same-frame chaining. + var play = MoveOrderList(idx: 41, from: 10, to: 20); + play["playIdx"] = 41L; play["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.KnownList, Is.Not.Null, "copy of a same-frame add resolved against the live map"); + Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(900_222_020L)); + } + + [Test] + public void Pvp_PlayActions_copy_with_unknown_baseIdx_degrades_to_no_knownList() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // A's play copies a baseIdx (99) that was never recorded → no identity to resolve. + var frame = 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 { 32L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["baseIdx"] = 99L, ["isPremium"] = 0L } } }, + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 32L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, frame)); + + var play = MoveOrderList(idx: 32, from: 10, to: 20); + play["playIdx"] = 32L; play["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.KnownList, Is.Null, "unknown baseIdx → no record → degrade to {playIdx,type}"); + } + + [Test] + public void Pvp_Echo_mines_copy_token_for_a_later_reveal() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // Frame 1: A plays a card adding a concrete token idx 31 (cardId 900_333_030) to A's hand. + var gen = new Dictionary + { + ["playIdx"] = 3L, ["type"] = 30L, + ["orderList"] = new List + { + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, + ["card"] = new Dictionary { ["cardId"] = 900_333_030L } } }, + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen)); + + // Frame 2: B sends an Echo describing a copy of A's idx 31 (isSelf:0 from B = the opponent A's + // index space) into a new token idx 32. Echo is mined but returns no routes. + var echo = new Dictionary + { + ["playIdx"] = 5L, ["type"] = 31L, + ["orderList"] = new List + { + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 32L }, ["isSelf"] = 0L, + ["card"] = new Dictionary { ["baseIdx"] = 31L, ["isPremium"] = 0L } } }, + }, + }; + var echoRoutes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.Echo, echo)); + Assert.That(echoRoutes, Is.Empty, "Echo is mined, never relayed"); + + // Frame 3: A plays the copy token idx 32; B must see its real identity. + var play = MoveOrderList(idx: 32, from: 10, to: 20); + play["playIdx"] = 32L; play["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(routes[0].Target, Is.SameAs(b)); + Assert.That(pb.KnownList, Is.Not.Null, "copy mined from the Echo into A's map"); + Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(900_333_030L)); + } + [Test] public void Pvp_PlayActions_choice_token_records_pick_and_strips_selectCard() {