using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Participants; namespace SVSim.UnitTests.BattleNode.Sessions; [TestFixture] public class BattleSessionDispatchTests { [Test] public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle() { var (s, a, b) = NewSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(routes[0].NoStock, Is.True); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); } [Test] public void InitBattle_pushes_Matched_to_sender_only() { var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] public void Loaded_pushes_BattleStart_then_Deal_to_sender() { var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } [Test] public void Swap_pushes_SwapResponse_then_Ready_to_sender() { var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] public void TurnEnd_from_real_forwards_to_other_participant() { var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); 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)); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase() { var a = new FakeRealParticipant(viewerId: 1, FixtureCtx()); var b = new FakeRealParticipant(viewerId: 2, FixtureCtx()); var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger.Instance); // A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set). b.Phase = BattleSessionPhase.AwaitingInitBattle; // A's InitNetwork should ack (matches A's phase). var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); Assert.That(routesA.Count, Is.EqualTo(1)); Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); // B's InitBattle should produce Matched (matches B's phase, set above). var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle)); Assert.That(routesB.Count, Is.EqualTo(1)); Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real() { var (s, a, b) = NewSession(); // Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the // ScriptedBotParticipant impl). Session should route it to the real participant. var botFrame = ScriptedLifecycle.BuildOpponentTurnStart(); var routes = s.ComputeFrames(b, botFrame); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); } [Test] public void ScriptedBot_emitted_Judge_forwards_to_real() { var (s, a, b) = NewSession(); var botFrame = ScriptedLifecycle.BuildOpponentJudge(); var routes = s.ComputeFrames(b, botFrame); 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)); } [Test] public void ScriptedBot_emitted_TurnEnd_forwards_to_real() { // TurnEnd from the bot is also one of the burst frames. The case is handled // by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId). var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot // arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch // arm that forwards any frame from the FakeOpponentViewerId participant. var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd(); var routes = s.ComputeFrames(b, botFrame); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); } [Test] public void Retire_pushes_BattleFinish_no_contest_terminates() { var (s, a, _) = NewSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(routes[0].NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void Kill_pushes_BattleFinish_no_contest_terminates() { var (s, a, _) = NewSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(routes[0].NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase() { var (s, a, _) = NewSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); Assert.That(routes, Is.Empty); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); } [Test] public void Pvp_InitBattle_from_A_pushes_Matched_with_B_oppoInfo_to_A_only() { var (s, a, b) = NewPvpSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); var body = (MatchedBody)routes[0].Frame.Body; Assert.That(body.SelfInfo.UserName, Is.EqualTo("PlayerA"), "Matched.selfInfo must reflect the sender (A)."); Assert.That(body.SelfInfo.OppoId, Is.EqualTo(b.ViewerId)); Assert.That(body.OppoInfo.UserName, Is.EqualTo("PlayerB"), "Matched.oppoInfo must reflect the OTHER participant (B)."); Assert.That(body.OppoInfo.OppoId, Is.EqualTo(a.ViewerId)); Assert.That(body.SelfInfo.Seed, Is.EqualTo(body.OppoInfo.Seed), "Both sides must see the same seed."); } [Test] public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only() { var (s, a, b) = NewPvpSession(); s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork)); var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); var body = (MatchedBody)routes[0].Frame.Body; Assert.That(body.SelfInfo.UserName, Is.EqualTo("PlayerB")); Assert.That(body.OppoInfo.UserName, Is.EqualTo("PlayerA")); } [Test] public void Pvp_Loaded_from_A_pushes_BattleStart_with_B_oppoInfo_plus_Deal_to_A() { var (s, a, b) = NewPvpSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); Assert.That(routes.Count, Is.EqualTo(2)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleStart)); Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.Deal)); Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); var bs = (BattleStartBody)routes[0].Frame.Body; Assert.That(bs.SelfInfo.ClassId, Is.EqualTo("3"), "A is class 3 per fixture."); Assert.That(bs.OppoInfo.ClassId, Is.EqualTo("5"), "B is class 5 per fixture."); } [Test] public void Pvp_Swap_from_A_pushes_SwapResponse_plus_Ready_to_A_only() { var (s, a, b) = NewPvpSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork), "Swap from A doesn't advance B's phase."); } [Test] public void Pvp_TurnStart_from_A_in_BothAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnStart)); 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)); } [Test] public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); 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)); } [Test] public void Pvp_Echo_from_A_in_BothAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); } [Test] public void Pvp_TurnEndActions_from_A_in_BothAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndActions)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(b)); } [Test] public void Pvp_JudgeResult_from_A_in_BothAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.JudgeResult)); Assert.That(routes.Count, Is.EqualTo(1)); 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() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); 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), })); } [Test] public void Pvp_TurnEndFinal_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal)); Assert.That(routes.Count, Is.EqualTo(4)); Assert.That(routes.Select(r => r.Frame.Uri).Distinct(), Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); } [Test] public void Pvp_TurnEnd_when_B_still_AwaitingSwap_drops() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); // B not at AfterReady. var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); Assert.That(routes, Is.Empty); } [Test] public void Pvp_Retire_from_A_pushes_BattleFinish_Lose_to_A_and_Win_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); Assert.That(routes.Count, Is.EqualTo(2)); var aRoute = routes.Single(r => ReferenceEquals(r.Target, a)); var bRoute = routes.Single(r => ReferenceEquals(r.Target, b)); Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Lose)); Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win)); Assert.That(aRoute.NoStock, Is.True); Assert.That(bRoute.NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void Pvp_Kill_from_A_same_as_Retire() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); DriveToAfterReady(s, b); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill)); Assert.That(routes.Count, Is.EqualTo(2)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void Scripted_Retire_still_pushes_BattleFinish_Win_to_sender_only() { // Regression guard — Phase 1 behavior preserved for Scripted. var (s, a, _) = NewSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win)); } private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession() { var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx()); var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, NoOpBotContext()); var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger.Instance); return (s, a, b); } private static MatchContext NoOpBotContext() => new( SelfDeckCardIds: Array.Empty(), ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", CountryCode: "", UserName: "Bot", SleeveId: "0", EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0); [Test] public void Bot_InitNetwork_acks_to_sender() { var (s, a, _) = NewBotSession(); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); } [Test] public void Bot_InitBattle_acks_to_sender_with_no_Matched_push() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); // Bot InitBattle is ack-only — NO Matched push. Matched would be ignored // by the client anyway (gated on status == Connect, which is already // past by the time the wire round-trip completes), but the spec is to // not send it for clarity. Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle), "Expected an ack envelope for InitBattle, NOT a Matched envelope."); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] public void Bot_Loaded_produces_no_routes_but_advances_phase() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); // Bot Loaded is silent — no BattleStart, no Deal. Pushing BattleStart // would actively CORRUPT OppoBattleStartInfo on the client (the wire // handler at Matching.cs:417 → SetNetworkInfo overwrites it with our // placeholder NoOpBotParticipant.Context zeros). Assert.That(routes, Is.Empty, "Bot Loaded is silent."); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap), "Phase still advances even though there are no outbound routes."); } [Test] public void Bot_Swap_per_sender_SwapResponse_plus_Ready() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] public void Bot_TurnEnd_pushes_Judge_to_sender_only() { var (s, a, b) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); Assert.That(routes.Count, Is.EqualTo(1), "Bot TurnEnd → exactly one Judge frame back."); Assert.That(routes[0].Target, Is.SameAs(a), "Judge target is the sender, not broadcast."); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); } [Test] public void Bot_TurnEndFinal_pushes_Judge_to_sender_only() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); Assert.That(routes[0].Target, Is.SameAs(a)); } [Test] public void Bot_PlayActions_drops_no_recipient() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // Bot's PlayActions falls through the default arm — the Pvp forwarder is gated // on Type == Pvp, so Bot's gameplay frames have no routing rule and drop. // (The PvP semantics would have been "forward to NoOp which swallows" — same // observable result, but cleaner to leave them as default-drops.) var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); Assert.That(routes, Is.Empty); } [Test] public void Bot_Retire_pushes_BattleFinish_Win_to_sender() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); } private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession() { var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx()); var b = new FakeRealParticipant(viewerId: 2002, PlayerBCtx()); var s = new BattleSession("bid-pvp-1", BattleType.Pvp, a, b, NullLogger.Instance); return (s, a, b); } private static void DriveToAfterReady(BattleSession s, FakeRealParticipant p) { s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Swap)); // p.Phase should now be AfterReady. } private static MatchContext PlayerACtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), ClassId: "3", CharaId: "3", CardMasterName: "card_master_node_10015", CountryCode: "KOR", UserName: "PlayerA", SleeveId: "3000011", EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); private static MatchContext PlayerBCtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(), ClassId: "5", CharaId: "5", CardMasterName: "card_master_node_10015", CountryCode: "JPN", UserName: "PlayerB", SleeveId: "3000022", EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0, BattleType: 11); private static (BattleSession, FakeRealParticipant, FakeParticipant) NewSession() { var a = new FakeRealParticipant(viewerId: 1, FixtureCtx()); var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext()); var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger.Instance); return (s, a, b); } private static MatchContext FixtureCtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); private static MatchContext ScriptedBotContext() => new( SelfDeckCardIds: Array.Empty(), ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, BattleType: 0); private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); /// 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 { public long ViewerId { get; } public MatchContext Context { get; } public event Func? FrameEmitted; public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; public Task RunAsync(CancellationToken ct) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; private void Touch() => FrameEmitted?.Invoke(null!, default); } /// Like but additionally implements /// so the dispatch tests can drive a participant's /// Phase without instantiating a full RealParticipant (which needs a real /// WebSocket). private sealed class FakeRealParticipant : IBattleParticipant, IHasHandshakePhase { public long ViewerId { get; } public MatchContext Context { get; } public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public event Func? FrameEmitted; public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; public Task RunAsync(CancellationToken ct) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; private void Touch() => FrameEmitted?.Invoke(null!, default); } }