From 553a79c7959de4570a4a9322aa066a038bfc1c70 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 19:55:00 -0400 Subject: [PATCH] feat(battle-node): add NoOpBotParticipant Silent participant for the Phase 3 Bot type. PushAsync swallows; FrameEmitted never fires; RunAsync completes immediately. ViewerId is the existing FakeOpponentViewerId const for consistency with scripted lifecycle builders. Three tests lock the no-op contract. --- .../Participants/NoOpBotParticipant.cs | 35 +++++++++++++++ .../Participants/NoOpBotParticipantTests.cs | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs create mode 100644 SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs diff --git a/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs new file mode 100644 index 0000000..3d5048f --- /dev/null +++ b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs @@ -0,0 +1,35 @@ +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Lifecycle; +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Sessions.Participants; + +/// +/// Silent participant — produces no frames, swallows everything pushed to it. +/// Used as the "other" participant in sessions, where +/// the real opponent runs in the client and the server has no opponent-side state +/// to model. ViewerId is ; +/// Context is a fixed stub (irrelevant — never read because no frames are pushed +/// to the other side). +/// +public sealed class NoOpBotParticipant : IBattleParticipant +{ + public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId; + public MatchContext Context { get; } = 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); + + public event Func? FrameEmitted; + + public Task PushAsync(MsgEnvelope envelope, 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; + + // Suppress unused-event warning — FrameEmitted is declared by the interface contract; + // intentionally never invoked. + private void Touch() => FrameEmitted?.Invoke(null!, default); +} diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs new file mode 100644 index 0000000..153bdb7 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs @@ -0,0 +1,44 @@ +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; +using SVSim.BattleNode.Sessions; +using SVSim.BattleNode.Sessions.Participants; + +namespace SVSim.UnitTests.BattleNode.Sessions.Participants; + +[TestFixture] +public class NoOpBotParticipantTests +{ + [Test] + public void PushAsync_swallows_without_firing_FrameEmitted() + { + var p = new NoOpBotParticipant(); + var fired = 0; + p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; }; + + var env = new MsgEnvelope( + NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, + Body: new ResultCodeOnlyBody()); + + Assert.DoesNotThrowAsync(() => p.PushAsync(env, noStock: false, CancellationToken.None)); + Assert.That(fired, Is.EqualTo(0)); + } + + [Test] + public async Task RunAsync_returns_immediately() + { + var p = new NoOpBotParticipant(); + await p.RunAsync(CancellationToken.None); + // If we got here, it returned. + Assert.Pass(); + } + + [Test] + public void ViewerId_is_FakeOpponent() + { + var p = new NoOpBotParticipant(); + Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId)); + } +}