diff --git a/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs new file mode 100644 index 0000000..025d762 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs @@ -0,0 +1,51 @@ +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Lifecycle; +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Sessions.Participants; + +/// +/// Server-scripted opponent (today's v1.2 testing-harness behavior, repackaged). +/// On with TurnEnd or TurnEndFinal, fires +/// three times: OpponentTurnStart, +/// OpponentTurnEnd, OpponentJudge. All other URIs are swallowed +/// (no opponent reaction needed for v1.2 behavior). +/// +/// +/// ViewerId, Context are fixtures matching +/// and a scripted opponent profile. Used by Phase 1 to preserve the v1.2 burst +/// behavior under the new participant abstraction; the session synthesizes +/// Matched/BattleStart/Deal using only the OTHER (real) participant's Context for now — +/// the scripted bot's Context is ignored for those frames in Phase 1. +/// +public sealed class ScriptedBotParticipant : IBattleParticipant +{ + public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId; + public MatchContext Context { get; } = 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); + + public event Func? FrameEmitted; + + public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) + { + // v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the + // three-frame burst. Everything else is silently swallowed. + if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal) + { + await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false); + await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false); + await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false); + } + } + + public Task RunAsync(CancellationToken ct) => Task.CompletedTask; + public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private Task EmitAsync(MsgEnvelope env, CancellationToken ct) => + FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask; +} diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs new file mode 100644 index 0000000..27c1573 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs @@ -0,0 +1,82 @@ +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 ScriptedBotParticipantTests +{ + [Test] + public async Task PushAsync_TurnEnd_fires_three_FrameEmitted_in_order() + { + var p = new ScriptedBotParticipant(); + var emitted = new List(); + p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; }; + + await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEnd), noStock: false, CancellationToken.None); + + Assert.That(emitted, Is.EqualTo(new[] + { + NetworkBattleUri.TurnStart, + NetworkBattleUri.TurnEnd, + NetworkBattleUri.Judge, + })); + } + + [Test] + public async Task PushAsync_TurnEndFinal_fires_three_FrameEmitted_in_order() + { + // Same burst as TurnEnd — TurnEndFinal is the game-ending variant but the + // bot's response shape is unchanged for v1.2 behaviour preservation. + var p = new ScriptedBotParticipant(); + var emitted = new List(); + p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; }; + + await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None); + + Assert.That(emitted, Is.EqualTo(new[] + { + NetworkBattleUri.TurnStart, + NetworkBattleUri.TurnEnd, + NetworkBattleUri.Judge, + })); + } + + [Test] + public async Task PushAsync_other_uris_do_not_fire() + { + var p = new ScriptedBotParticipant(); + var fired = 0; + p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; }; + + foreach (var uri in new[] + { + NetworkBattleUri.Matched, NetworkBattleUri.BattleStart, NetworkBattleUri.Deal, + NetworkBattleUri.Swap, NetworkBattleUri.Ready, NetworkBattleUri.PlayActions, + NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEndActions, NetworkBattleUri.Echo, + NetworkBattleUri.Judge, NetworkBattleUri.BattleFinish, + }) + { + await p.PushAsync(NewEnvelope(uri), noStock: false, CancellationToken.None); + } + + Assert.That(fired, Is.EqualTo(0)); + } + + [Test] + public async Task RunAsync_returns_immediately() + { + var p = new ScriptedBotParticipant(); + await p.RunAsync(CancellationToken.None); + Assert.Pass(); + } + + 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 ResultCodeOnlyBody()); +}