// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; namespace SVSim.UnitTests.BattleNode.Sessions; [TestFixture] public class BattleSessionDispatchTests { private static BattleSession NewSession() { // ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump. return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, context: FixtureCtx(), log: NullLogger.Instance); } private static MatchContext FixtureCtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 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 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())); [Test] public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle() { var s = NewSession(); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); Assert.That(responses.Select(r => r.Envelope.Uri), Is.EqualTo(new[] { NetworkBattleUri.InitNetwork })); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); } [Test] public void InitBattle_PushesMatched_TransitionsToAwaitingLoaded() { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap() { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); Assert.That(responses.Select(r => r.Envelope.Uri), Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } [Test] public void Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1() { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); // Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson // (a List of boxed long values), wrapped in a RawBody as the inbound type. var swapEnv = new MsgEnvelope( NetworkBattleUri.Swap, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary { ["idxList"] = new List { 2L }, })); var responses = s.ComputeResponses(swapEnv); var swapBody = (SwapResponseBody)responses[0].Envelope.Body; Assert.That(swapBody.Self[0].Idx, Is.EqualTo(1)); Assert.That(swapBody.Self[1].Idx, Is.EqualTo(4)); // swapped — fresh deck idx Assert.That(swapBody.Self[2].Idx, Is.EqualTo(3)); } [Test] public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady() { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); Assert.That(responses.Select(r => r.Envelope.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] public void TurnEnd_AfterReady_PushesOpponentTurnStart_ThenTurnEnd_StaysInAfterReady() { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd)); // Two-frame cycle: opponent opens its turn, immediately ends it, hands control back. Assert.That(responses.Select(r => r.Envelope.Uri), Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd })); Assert.That(responses.Select(r => r.NoStock), Is.EqualTo(new[] { false, false })); // Phase returns to AfterReady within the same call; the next player TurnEnd can fire // the cycle again. (OpponentTurn is set transiently inside ComputeResponses to document // intent, but is never externally observable.) Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal() { var s = NewSession(); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire)); var (env, noStock) = responses.Single(); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(noStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void Kill_PushesBattleFinishNoContest_TransitionsToTerminal() { var s = NewSession(); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Kill)); var (env, noStock) = responses.Single(); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(noStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] public void Swap_ArrivingBeforeLoaded_ProducesNoResponseAndDoesNotAdvancePhase() { var s = NewSession(); // Skip Loaded — fire Swap straight out of AwaitingInitNetwork. var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); Assert.That(responses, Is.Empty); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); } }