diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs new file mode 100644 index 0000000..1bdabf8 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs @@ -0,0 +1,209 @@ +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; + +namespace SVSim.UnitTests.BattleNode.Sessions; + +[TestFixture] +public class BattleSessionV2DispatchTests +{ + [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(s.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(s.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(s.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(s.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(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); + } + + [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(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); + } + + private static (BattleSessionV2, FakeParticipant, FakeParticipant) NewSession() + { + var a = new FakeParticipant(viewerId: 1, FixtureCtx()); + var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext()); + var s = new BattleSessionV2("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); + } +}