diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index b4873b8..5654059 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -4,15 +4,17 @@ namespace SVSim.BattleNode.Sessions.Dispatch; /// Mutable per-session state shared across frame handlers. The mulligan barrier's /// post-swap hands, plus (PvP-equivalency, vanilla slice) the per-side idx->cardId map used to -/// synthesize the opponent-facing knownList. FUTURE: a token map (cardIds mined from -/// orderList add ops, idx>30) + a reveal-gate set land alongside . +/// synthesize the opponent-facing knownList. Generated tokens (cardIds mined from +/// orderList add ops) are recorded into the SAME +/// map via ; a reveal-gate set is still future. internal sealed class BattleSessionState { public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public Dictionary PostSwapHands { get; } = new(); /// Per-side idx->cardId, seeded lazily from . - /// Deck cards only (idx 1..deckCount); tokens (idx>deckCount) are deferred. + /// Holds deck cards (idx 1..deckCount, seeded) and generated tokens (idx>deckCount, recorded + /// from add ops via ). public Dictionary> IdxToCardId { get; } = new(); /// The sender's idx->cardId map, seeding it from its on first @@ -28,4 +30,15 @@ internal sealed class BattleSessionState } return map; } + + /// Record a generated token's identity into the side's idx->cardId map (the same map + /// that holds deck cards). Mined from the sender's orderList add ops by + /// ; surfaced later by BuildPlayedCard when the + /// token is the played card. Deck idxs (1..deckCount) and token idxs (>deckCount) don't + /// collide — the client allocates token idxs after the deck. + public void RecordToken(IBattleParticipant side, int idx, long cardId) + { + GetOrSeedDeckMap(side); // ensure the per-side map exists (deck-seeded) + IdxToCardId[side][idx] = cardId; // overwrite-on-conflict: latest identity wins + } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index f591f30..ba9184b 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -5,8 +5,9 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; /// PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing /// knownList from the sender's idx->cardId map + the orderList move op, renames targetList -> -/// oppoTargetList, drops orderList, consumes keyAction. Token plays (idx>deck) degrade silently to -/// {playIdx,type} (no knownList). Bot drop (no rule). +/// oppoTargetList, drops orderList, consumes keyAction. +/// Token plays resolve their cardId from add ops mined on earlier 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) @@ -18,8 +19,15 @@ internal sealed class PlayActionsHandler : IFrameHandler var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx")); var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type")); + 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); + var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); - var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList")); + var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList); var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList")); var body = new PlayActionsBroadcastBody( diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index d9409da..a9e1ab8 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -250,7 +250,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_PlayActions_token_idx_degrades_to_no_knownList() + public void Pvp_PlayActions_ungenerated_token_idx_degrades_to_no_knownList() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); @@ -268,6 +268,49 @@ public class BattleSessionDispatchTests Assert.That(pb.KnownList, Is.Null); } + [Test] + public void Pvp_PlayActions_reveals_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 (a spell, hand 10 -> cemetery 30) whose fanfare ADDS + // token idx 31 (cardId 900111010) 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"] = 900111010L } } }, + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } }, + }, + }; + var genRoutes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen)); + // The deck card itself reveals from the deck map; the token stays hidden (in hand). + var genBody = (PlayActionsBroadcastBody)genRoutes[0].Frame.Body; + Assert.That(genBody.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "deck card revealed"); + + // Frame 2 (later turn): A plays token idx 31 from hand (10) to field (20). + var play = MoveOrderList(idx: 31, from: 10, to: 20); + play["playIdx"] = 31L; + 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(31)); + Assert.That(pb.KnownList, Is.Not.Null, "the token's identity was remembered from its add op"); + Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "mined token cardId"); + Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); + } + [Test] public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops() {