diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs new file mode 100644 index 0000000..b93f9ac --- /dev/null +++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace SVSim.BattleNode.Protocol.Bodies; + +/// Opponent-facing PlayActions frame the node synthesizes from the active player's +/// send. KnownList reveals the played card's identity (null = token reveal deferred, see +/// the deterministic-turn slice). OppoTargetList is the renamed targetList +/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the +/// envelope's WhenWritingNull policy. +public sealed record PlayActionsBroadcastBody( + [property: JsonPropertyName("playIdx")] int PlayIdx, + [property: JsonPropertyName("type")] int Type, + [property: JsonPropertyName("knownList")] IReadOnlyList? KnownList, + [property: JsonPropertyName("oppoTargetList")] IReadOnlyList? OppoTargetList) : IMsgBody; + +/// One revealed card in a knownList. Vanilla slice fills cardId from the sender's +/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master +/// port — the receiver re-derives them from cardId). +public sealed record KnownCardEntry( + [property: JsonPropertyName("idx")] int Idx, + [property: JsonPropertyName("cardId")] long CardId, + [property: JsonPropertyName("to")] int To, + [property: JsonPropertyName("spellboost")] int Spellboost, + [property: JsonPropertyName("attachTarget")] string AttachTarget); + +/// Renamed targetList entry. isSelf is actor-relative and passes through +/// verbatim — no perspective flip (bullet-3 audit F2). +public sealed record OppoTargetEntry( + [property: JsonPropertyName("targetIdx")] int TargetIdx, + [property: JsonPropertyName("isSelf")] int IsSelf); diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 81f1b15..6a04b60 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -49,9 +49,9 @@ public sealed class BattleSession [NetworkBattleUri.Kill] = retireKill, [NetworkBattleUri.TurnStart] = new TurnStartHandler(), [NetworkBattleUri.Judge] = new JudgeHandler(), - [NetworkBattleUri.PlayActions] = forwardWhenReady, - [NetworkBattleUri.Echo] = forwardWhenReady, - [NetworkBattleUri.TurnEndActions] = forwardWhenReady, + [NetworkBattleUri.PlayActions] = new PlayActionsHandler(), + [NetworkBattleUri.Echo] = new EchoHandler(), + [NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(), [NetworkBattleUri.JudgeResult] = forwardWhenReady, }; } diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index 3a53df5..b4873b8 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -1,11 +1,31 @@ +using SVSim.BattleNode.Sessions; + namespace SVSim.BattleNode.Sessions.Dispatch; -/// Mutable per-session state shared across frame handlers. Today: the session-level -/// phase (only ever advanced to ) and the mulligan -/// barrier's post-swap hands. FUTURE (PvP equivalency, NOT this refactor): per-side idx->cardId -/// maps + reveal-gating state land here. +/// 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 . 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. + public Dictionary> IdxToCardId { get; } = new(); + + /// The sender's idx->cardId map, seeding it from its on first + /// use. BuildPlayerDeck assigns deck idx = position+1, so entry (i+1) -> cardIds[i]. + public IReadOnlyDictionary GetOrSeedDeckMap(IBattleParticipant side) + { + if (!IdxToCardId.TryGetValue(side, out var map)) + { + map = new Dictionary(); + var deck = side.Context.SelfDeckCardIds; + for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i]; + IdxToCardId[side] = map; + } + return map; + } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs new file mode 100644 index 0000000..27dfbb5 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs @@ -0,0 +1,8 @@ +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. +internal sealed class EchoHandler : IFrameHandler +{ + public IReadOnlyList Handle(FrameDispatchContext ctx) => Array.Empty(); +} diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs index 4ba8146..563d96b 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; @@ -6,9 +7,23 @@ internal sealed class JudgeHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - // Only a scripted-bot Judge is forwarded. A real player's Judge has no routing rule (drops). + // Scripted-bot Judge (test stub): forward verbatim (carries the {spin} shape already). if (ctx.IsScriptedBot(ctx.From)) return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) }; + + // PvP: Judge is the handover gate. The player who sends Judge is the one TAKING OVER the + // turn (the client rule is: receive opponent TurnEnd -> SendJudge). Receiving Judge{spin} + // fires ControlTurnStartPlayer ("start MY turn"), so the {spin} must REFLECT BACK to the + // sender — NOT go to the opponent (that would make the player who just ended their turn + // start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture). + // The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}. + // battleCode is dropped; spin=0 for the deterministic-turn slice. + if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + { + var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) }; + return new[] { new DispatchRoute(ctx.From, frame, false) }; + } + return Array.Empty(); } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs new file mode 100644 index 0000000..6d76df4 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -0,0 +1,34 @@ +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; + +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). Scripted/Bot drop (no rule). +internal sealed class PlayActionsHandler : IFrameHandler +{ + public IReadOnlyList Handle(FrameDispatchContext ctx) + { + if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady()) + return Array.Empty(); + + var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary(); + var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx")); + var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type")); + + var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); + var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList")); + var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList")); + + var body = new PlayActionsBroadcastBody( + PlayIdx: playIdx, + Type: type, + KnownList: played is null ? null : new[] { played }, + OppoTargetList: oppoTargets); + + var frame = ctx.Env with { Body = body }; + return new[] { new DispatchRoute(ctx.Other, frame, false) }; + } +} diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs new file mode 100644 index 0000000..96d95c1 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs @@ -0,0 +1,19 @@ +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; + +/// PvP TurnEndActions: the sender's orderList is dropped; the opponent receives an +/// empty body (it only flips _sendEcho + runs the opponent's end-of-turn triggers via the +/// opponent's own engine). Scripted/Bot drop. +internal sealed class TurnEndActionsHandler : IFrameHandler +{ + public IReadOnlyList Handle(FrameDispatchContext ctx) + { + if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + { + var frame = ctx.Env with { Body = new RawBody(new Dictionary()) }; + return new[] { new DispatchRoute(ctx.Other, frame, false) }; + } + return Array.Empty(); + } +} diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs index 9f29501..69fb6f0 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; @@ -16,15 +17,11 @@ internal sealed class TurnEndHandler : IFrameHandler { if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) { - var te = BattleFrames.BuildTurnEndBroadcast(); - var jg = BattleFrames.BuildJudgeBroadcast(); - return new[] - { - new DispatchRoute(ctx.From, te, false), - new DispatchRoute(ctx.Other, te, false), - new DispatchRoute(ctx.From, jg, false), - new DispatchRoute(ctx.Other, jg, false), - }; + // Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate): + // the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects + // back to it to start its turn. battleCode/actionSeq/cemetery are dropped. + var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) }; + return new[] { new DispatchRoute(ctx.Other, te, false) }; } if (ctx.Type == BattleType.Scripted) return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) }; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs index ff7e102..348cdcf 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Sessions.Dispatch.Handlers; @@ -6,10 +7,18 @@ internal sealed class TurnStartHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - // Forward the opponent's turn-open to the other side. Union of the two legacy arms: - // BothAfterReady (PvP / scripted real player) OR a scripted-bot emission (test stub path). - if (ctx.BothAfterReady() || ctx.IsScriptedBot(ctx.From)) + // PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin} + // (spin=0 for the deterministic-turn slice) and self-generates its turn-open. + if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + { + var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) }; + return new[] { new DispatchRoute(ctx.Other, frame, false) }; + } + + // Scripted-bot emission (test stub path): the bot already emits the {spin} shape — forward. + if (ctx.IsScriptedBot(ctx.From)) return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) }; + return Array.Empty(); } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs new file mode 100644 index 0000000..c0e3084 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -0,0 +1,74 @@ +using SVSim.BattleNode.Protocol.Bodies; + +namespace SVSim.BattleNode.Sessions.Dispatch; + +/// Pure transforms from the active player's RawBody sub-structures to the opponent-facing +/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive +/// as Dictionary<string,object?> / List<object?> with numeric leaves boxed +/// as long/int/double (see MsgEnvelope.FromJson). +internal static class KnownListBuilder +{ + /// The played card's knownList entry, or null when its identity can't be synthesized + /// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to + /// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId). + public static KnownCardEntry? BuildPlayedCard( + IReadOnlyDictionary deckMap, int playIdx, object? orderList) + { + if (!deckMap.TryGetValue(playIdx, out var cardId)) return null; + var to = ExtractMoveTo(orderList, playIdx); + if (to is null) return null; + return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: ""); + } + + /// The to place-state of the FIRST move op whose idx list contains + /// (the played card's own move; later add/alter ops are the deferred + /// token slice), or null if absent. NOTE: the sender-side to is passed through verbatim — + /// for the vanilla slice we assume send-side and recv-side place-state codes match, pending + /// recv-capture confirmation. + public static int? ExtractMoveTo(object? orderList, int playIdx) + { + if (orderList is not IEnumerable ops) return null; + foreach (var op in ops) + { + if (op is not IDictionary opDict) continue; + if (!opDict.TryGetValue("move", out var moveRaw) || moveRaw is not IDictionary move) continue; + if (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable idxList) + { + foreach (var i in idxList) + if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw)) + return (int)AsLong(toRaw); + } + } + return null; + } + + /// Rename targetList -> oppoTargetList; isSelf is actor-relative + /// and passes through unchanged (F2). Null for a missing/empty list. + public static IReadOnlyList? RenameTargets(object? targetList) + { + if (targetList is not IEnumerable entries) return null; + var result = new List(); + foreach (var e in entries) + { + if (e is not IDictionary d) continue; + d.TryGetValue("targetIdx", out var targetIdxRaw); + d.TryGetValue("isSelf", out var isSelfRaw); + result.Add(new OppoTargetEntry( + TargetIdx: (int)AsLong(targetIdxRaw), + IsSelf: (int)AsLong(isSelfRaw))); + } + return result.Count == 0 ? null : result; + } + + /// Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for + /// null/unparseable. + public static long AsLong(object? value) => value switch + { + long l => l, + int i => i, + double d => (long)d, + decimal m => (long)m, + string s when long.TryParse(s, out var p) => p, + _ => 0, + }; +} diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index acababe..452af9e 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -220,21 +220,28 @@ public class BattleNodeFlowTests await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct); - // Both are now AfterReady. A sends TurnEnd; both should receive TurnEnd + Judge. + // Both are now AfterReady. Deterministic-turn handover, mirroring the real two-client + // capture (2026-06-03 battle_test). A ends its turn; the OPPONENT (B) receives the + // translated {turnState:0} TurnEnd. A receives nothing — it already ran the turn locally. await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct); + var bTurnEnd = await clientB.ReceiveSynchronizeAsync(ct); + Assert.That(bTurnEnd.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); - var aFirst = await clientA.ReceiveSynchronizeAsync(ct); - var aSecond = await clientA.ReceiveSynchronizeAsync(ct); - var bFirst = await clientB.ReceiveSynchronizeAsync(ct); - var bSecond = await clientB.ReceiveSynchronizeAsync(ct); + // The client rule is: receive opponent TurnEnd -> SendJudge. So B (the taker-over) sends + // Judge. The {spin:0} reflects BACK to B (its own ControlTurnStartPlayer gate), NOT to A — + // routing it to A would restart A's turn and stall the loop (the live-run bug this fixes). + await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.Judge, pubSeq: 5), key, ct); + var bJudge = await clientB.ReceiveSynchronizeAsync(ct); + Assert.That(bJudge.Uri, Is.EqualTo(NetworkBattleUri.Judge)); - Assert.That(new[] { aFirst.Uri, aSecond.Uri }, - Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); - Assert.That(new[] { bFirst.Uri, bSecond.Uri }, - Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); + // B opens its turn: TurnStart relays to the opponent A as {spin:0} ("opponent's turn"). + await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.TurnStart, pubSeq: 6), key, ct); + var aTurnStart = await clientA.ReceiveSynchronizeAsync(ct); + Assert.That(aTurnStart.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); - // PlayActions forwarding: B sends, A receives. - await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 6), key, ct); + // PlayActions translation: B plays a card; A receives the opponent-facing PlayActions + // frame (Uri preserved, body synthesized by PlayActionsHandler). + await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 7), key, ct); var aForwarded = await clientA.ReceiveSynchronizeAsync(ct); Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions)); } diff --git a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs index bdc97aa..44f3c13 100644 --- a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -221,6 +221,48 @@ public class CaptureConformanceTests var seq = 0; return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16); } + + [Test] + public void SynthesizedKnownList_matches_prod_recv_PlayActions_entry_shape() + { + // Prod recv PlayActions knownList entry (battle-traffic_tk2_regular.ndjson:27). + const string prodEntry = """ + { "idx": 17, "cardId": 128821011, "to": 20, "cost": 2, "clan": 8, "tribe": "7,16", "spellboost": 0, "attachTarget": "" } + """; + + // Build the same entry through our synthesizer. + var deckMap = new Dictionary { [17] = 128821011L }; + var orderList = new List + { + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { 17L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L, + } + } + }; + var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 17, orderList); + Assert.That(entry, Is.Not.Null); + + var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( + PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null); + var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); + + using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); + var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0]; + using var prodDoc = JsonDocument.Parse(prodEntry); + + // We are responsible for idx/cardId/to (+ spellboost/attachTarget). cost/clan/tribe are deferred. + foreach (var key in new[] { "idx", "cardId", "to" }) + { + Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'"); + var prodVal = prodDoc.RootElement.GetProperty(key); + Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch"); + } + Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(128821011L)); + } } /// diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 31f417b..9faf1c6 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -384,7 +384,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_TurnStart_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_TurnStart_from_A_emits_spin0_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); @@ -395,24 +395,111 @@ public class BattleSessionDispatchTests Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); + var body = (SVSim.BattleNode.Protocol.Bodies.OpponentTurnStartBody)routes[0].Frame.Body; + Assert.That(body.Spin, Is.EqualTo(0)); } [Test] - public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_Judge_from_A_reflects_spin0_back_to_sender() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Judge)); + + // Judge reflects BACK to its sender (the turn taker-over), not to the opponent: receiving + // Judge{spin} fires the sender's ControlTurnStartPlayer. Routing to the opponent would + // restart the just-ended player's turn (2026-06-03 two-client capture). + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + var body = (SVSim.BattleNode.Protocol.Bodies.JudgeBody)routes[0].Frame.Body; + Assert.That(body.Spin, Is.EqualTo(0)); + } + + [Test] + public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + var body = MoveOrderList(idx: 3, from: 10, to: 20); + body["playIdx"] = 3L; + body["type"] = 30L; + + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions)); + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.PlayIdx, Is.EqualTo(3)); + Assert.That(pb.Type, Is.EqualTo(30)); + Assert.That(pb.KnownList!.Count, Is.EqualTo(1)); + Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId + Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); + Assert.That(pb.OppoTargetList, Is.Null); } [Test] - public void Pvp_Echo_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_PlayActions_renames_targetList_to_oppoTargetList() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + var body = MoveOrderList(idx: 3, from: 10, to: 20); + body["playIdx"] = 3L; + body["type"] = 31L; + body["targetList"] = new List + { + new Dictionary { ["targetIdx"] = 8L, ["isSelf"] = 0L }, + }; + + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + + Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1)); + Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8)); + Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0)); + } + + [Test] + public void Pvp_PlayActions_token_idx_degrades_to_no_knownList() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + var body = MoveOrderList(idx: 31, from: 10, to: 20); // idx 31 > 30-card deck → token + body["playIdx"] = 31L; + body["type"] = 30L; + + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(pb.PlayIdx, Is.EqualTo(31)); + Assert.That(pb.KnownList, Is.Null); + } + + [Test] + public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + // B not AfterReady → not BothAfterReady. + var body = MoveOrderList(3, 10, 20); + body["playIdx"] = 3L; body["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); + Assert.That(routes, Is.Empty); + } + + [Test] + public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); @@ -420,21 +507,23 @@ public class BattleSessionDispatchTests var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo)); - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(b)); + Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm."); } [Test] - public void Pvp_TurnEndActions_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndActions)); + var body = MoveOrderList(3, 20, 30); // a non-empty orderList that must be dropped + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.TurnEndActions, body)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndActions)); + Assert.That(((RawBody)routes[0].Frame.Body).Entries, Is.Empty, "orderList is dropped; body is empty."); } [Test] @@ -451,20 +540,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops() - { - var (s, a, b) = NewPvpSession(); - DriveToAfterReady(s, a); - // B is still AwaitingInitNetwork — BothAfterReady is false. - - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); - - Assert.That(routes, Is.Empty, - "PvP gameplay forwarding must wait until BOTH sides reach AfterReady."); - } - - [Test] - public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both() + public void Pvp_TurnEnd_from_A_emits_turnState_to_B_only() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); @@ -472,14 +548,11 @@ public class BattleSessionDispatchTests var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); - Assert.That(routes.Count, Is.EqualTo(4)); - Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[] - { - ((IBattleParticipant)a, NetworkBattleUri.TurnEnd), - ((IBattleParticipant)b, NetworkBattleUri.TurnEnd), - ((IBattleParticipant)a, NetworkBattleUri.Judge), - ((IBattleParticipant)b, NetworkBattleUri.Judge), - })); + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(b)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); + var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body; + Assert.That(body.TurnState, Is.EqualTo(0)); } [Test] @@ -776,6 +849,25 @@ public class BattleSessionDispatchTests Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); + private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary body) => + new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body)); + + private static Dictionary MoveOrderList(int idx, int from, int to) => new() + { + ["orderList"] = new List + { + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { (long)idx }, + ["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to, + } + } + } + }; + /// Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync /// are no-ops; FrameEmitted exists but is never invoked by the test. private sealed class FakeParticipant : IBattleParticipant diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs new file mode 100644 index 0000000..873984a --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; +using SVSim.BattleNode.Sessions.Dispatch; + +namespace SVSim.UnitTests.BattleNode.Sessions; + +[TestFixture] +public class BattleSessionStateTests +{ + private sealed class StubParticipant : IBattleParticipant + { + public long ViewerId { get; } + public MatchContext Context { get; } + public event Func? FrameEmitted; + public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; } + public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, bool n, CancellationToken c) => Task.CompletedTask; + public Task RunAsync(CancellationToken c) => Task.CompletedTask; + public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + private void Touch() => FrameEmitted?.Invoke(null!, default); + } + + private static MatchContext Ctx(params long[] deck) => new( + SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm", + CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0", + FieldId: 0, IsOfficial: 0, BattleType: 11); + + [Test] + public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds() + { + var state = new BattleSessionState(); + var p = new StubParticipant(1, Ctx(900L, 901L, 902L)); + + var map = state.GetOrSeedDeckMap(p); + + Assert.That(map[1], Is.EqualTo(900L)); + Assert.That(map[2], Is.EqualTo(901L)); + Assert.That(map[3], Is.EqualTo(902L)); + Assert.That(map.ContainsKey(4), Is.False); + } + + [Test] + public void GetOrSeedDeckMap_is_idempotent_same_instance() + { + var state = new BattleSessionState(); + var p = new StubParticipant(1, Ctx(900L)); + Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p))); + } +} diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs new file mode 100644 index 0000000..d983c6a --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using SVSim.BattleNode.Sessions.Dispatch; + +namespace SVSim.UnitTests.BattleNode.Sessions; + +[TestFixture] +public class KnownListBuilderTests +{ + // orderList as it arrives in a RawBody: a list of single-key op dicts. + private static List OrderListMove(int idx, int from, int to) => new() + { + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { (long)idx }, + ["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to, + } + } + }; + + [Test] + public void ExtractMoveTo_returns_to_for_matching_idx() + { + var to = KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 3); + Assert.That(to, Is.EqualTo(20)); + } + + [Test] + public void ExtractMoveTo_returns_null_when_no_move_op_matches() + { + Assert.That(KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 99), Is.Null); + Assert.That(KnownListBuilder.ExtractMoveTo(null, playIdx: 3), Is.Null); + } + + [Test] + public void ExtractMoveTo_returns_first_matching_move_op() + { + // A real PlayActions can carry several move ops; the played card's move comes first, + // later ops (token add/alter) target other idxs. Confirm first-match-wins, not last. + var orderList = new List + { + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L, + } + }, + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { 31L, 32L }, ["isSelf"] = 1L, ["from"] = 0L, ["to"] = 40L, + } + }, + }; + Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 3), Is.EqualTo(30)); + Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 31), Is.EqualTo(40)); + } + + [Test] + public void BuildPlayedCard_returns_null_for_deck_card_with_no_matching_move_op() + { + // idx is in the deck, but the orderList has no move op for it → can't synthesize. + var deckMap = new Dictionary { [3] = 128821011L }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(7, 10, 20)); + Assert.That(entry, Is.Null); + } + + [Test] + public void BuildPlayedCard_synthesizes_entry_for_deck_card() + { + var deckMap = new Dictionary { [3] = 128821011L }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20)); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Idx, Is.EqualTo(3)); + Assert.That(entry.CardId, Is.EqualTo(128821011L)); + Assert.That(entry.To, Is.EqualTo(20)); + Assert.That(entry.Spellboost, Is.EqualTo(0)); + Assert.That(entry.AttachTarget, Is.EqualTo("")); + } + + [Test] + public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck() + { + var deckMap = new Dictionary { [3] = 128821011L }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 31, orderList: OrderListMove(31, 10, 20)); + Assert.That(entry, Is.Null); + } + + [Test] + public void RenameTargets_passes_isSelf_through_verbatim() + { + var targetList = new List + { + new Dictionary { ["targetIdx"] = 8L, ["isSelf"] = 0L }, + }; + var renamed = KnownListBuilder.RenameTargets(targetList); + + Assert.That(renamed, Is.Not.Null); + Assert.That(renamed!.Count, Is.EqualTo(1)); + Assert.That(renamed[0].TargetIdx, Is.EqualTo(8)); + Assert.That(renamed[0].IsSelf, Is.EqualTo(0)); + } + + [Test] + public void RenameTargets_returns_null_for_missing_or_empty() + { + Assert.That(KnownListBuilder.RenameTargets(null), Is.Null); + Assert.That(KnownListBuilder.RenameTargets(new List()), Is.Null); + } +}