diff --git a/SVSim.UnitTests/BattleNode/Sessions/ScriptedBotHandshakeIntegrationTests.cs b/SVSim.UnitTests/BattleNode/Sessions/ScriptedBotHandshakeIntegrationTests.cs new file mode 100644 index 0000000..7f45d29 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/ScriptedBotHandshakeIntegrationTests.cs @@ -0,0 +1,76 @@ +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 ScriptedBotHandshakeIntegrationTests +{ + [Test] + public async Task RealBot_completes_handshake_and_both_sides_reach_Ready_through_barrier() + { + var bot = new ScriptedBotParticipant(); // B — real participant under test + var player = new RecordingPlayer(viewerId: 1, PlayerCtx()); + + var session = new BattleSession("bid-int-1", BattleType.Scripted, player, bot, + NullLogger.Instance); + + // Wire the bot's emissions into the session the way BattleSession's ctor does for + // real participants (the ctor already subscribed; we just need to start RunAsync, + // which fires the bot's InitNetwork and lets the reactive cascade settle). + await bot.RunAsync(CancellationToken.None); + + // After the bot's cascade, the bot has emitted InitNetwork→InitBattle→Loaded→Swap + // and is parked awaiting the player's Swap (barrier holds its Ready). + Assert.That(((IHasHandshakePhase)bot).Phase, Is.EqualTo(BattleSessionPhase.AfterReady), + "Bot should have driven its own handshake to AfterReady."); + Assert.That(player.Received, Does.Not.Contain(NetworkBattleUri.Ready), + "Player hasn't swapped yet → barrier must withhold the bot-triggered Ready from the player too."); + + // Now drive the player's handshake through Swap; the player's Swap should release + // Ready to BOTH sides. + session.ComputeFrames(player, Env(NetworkBattleUri.InitNetwork)); + session.ComputeFrames(player, Env(NetworkBattleUri.InitBattle)); + var loadedRoutes = session.ComputeFrames(player, Env(NetworkBattleUri.Loaded)); + var bs = (BattleStartBody)loadedRoutes[0].Frame.Body; + Assert.That(bs.TurnState, Is.EqualTo(0), "Player (A) goes first."); + + var swapRoutes = session.ComputeFrames(player, Env(NetworkBattleUri.Swap)); + Assert.That(swapRoutes.Any(r => ReferenceEquals(r.Target, player) && r.Frame.Uri == NetworkBattleUri.Ready), + Is.True, "Player's Swap (the second) releases Ready to the player."); + Assert.That(swapRoutes.Any(r => ReferenceEquals(r.Target, bot) && r.Frame.Uri == NetworkBattleUri.Ready), + Is.True, "...and to the bot."); + } + + private sealed class RecordingPlayer : IBattleParticipant, IHasHandshakePhase + { + public long ViewerId { get; } + public MatchContext Context { get; } + public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; + public List Received { get; } = new(); + public event Func? FrameEmitted; + public RecordingPlayer(long viewerId, MatchContext ctx) { ViewerId = viewerId; Context = ctx; } + public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) + { Received.Add(env.Uri); return 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); + } + + private static MatchContext PlayerCtx() => 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 MsgEnvelope Env(NetworkBattleUri uri) => + new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, + PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); +}