diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 81f1b15..e6d209b 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -49,7 +49,7 @@ public sealed class BattleSession [NetworkBattleUri.Kill] = retireKill, [NetworkBattleUri.TurnStart] = new TurnStartHandler(), [NetworkBattleUri.Judge] = new JudgeHandler(), - [NetworkBattleUri.PlayActions] = forwardWhenReady, + [NetworkBattleUri.PlayActions] = new PlayActionsHandler(), [NetworkBattleUri.Echo] = forwardWhenReady, [NetworkBattleUri.TurnEndActions] = forwardWhenReady, [NetworkBattleUri.JudgeResult] = forwardWhenReady, diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs new file mode 100644 index 0000000..9f22097 --- /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 to +/// {playIdx,type} with a debug log. 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.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 31f417b..537eb82 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -398,17 +398,83 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); + 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_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] @@ -450,19 +516,6 @@ public class BattleSessionDispatchTests Assert.That(routes[0].Target, Is.SameAs(b)); } - [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() { @@ -776,6 +829,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