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());
+}