diff --git a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
index d2ec4d9..3a0caed 100644
--- a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
+++ b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
@@ -28,6 +28,13 @@ public sealed class RealParticipant : IBattleParticipant
public InboundTracker Inbound { get; } = new();
public OutboundSequencer Outbound { get; } = new();
+ /// Per-side handshake progression. Session reads this when gating
+ /// handshake-phase synthesis (Matched / BattleStart / Deal / Swap response /
+ /// Ready). Session transitions via the setter after dispatch. Defaults to
+ /// AwaitingInitNetwork; only RealParticipant tracks this — bots have no phase
+ /// because they never send the gating URIs.
+ internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
+
public event Func? FrameEmitted;
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs
index 2613657..79d4049 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs
@@ -90,6 +90,43 @@ public class RealParticipantTests
Assert.That(result, Is.EqualTo(int.MinValue));
}
+ [Test]
+ public void Phase_defaults_to_AwaitingInitNetwork()
+ {
+ var ws = new TestWebSocket();
+ var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
+ NullLogger.Instance);
+
+ Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork));
+ }
+
+ [Test]
+ public void Phase_setter_is_visible_to_same_assembly()
+ {
+ var ws = new TestWebSocket();
+ var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
+ NullLogger.Instance);
+
+ // Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
+ p.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
+
+ Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady));
+ }
+
+ [Test]
+ public void Phase_is_per_instance_not_shared()
+ {
+ var wsA = new TestWebSocket();
+ var wsB = new TestWebSocket();
+ var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger.Instance);
+ var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger.Instance);
+
+ a.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
+
+ Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork),
+ "B's Phase must not change when A's Phase is set.");
+ }
+
private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",