diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index affd46e..fe19c48 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
+using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Sessions;
@@ -86,17 +87,20 @@ public sealed class BattleSession
{
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
var other = ReferenceEquals(from, A) ? B : A;
+ var phaseFrom = from as IHasHandshakePhase;
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
- // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches.
+ // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
+ // arms read the SENDER's Phase (per-participant); the session-level Phase
+ // remains only for the Terminal short-circuit.
switch (env.Uri)
{
- case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
+ case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
- Phase = BattleSessionPhase.AwaitingInitBattle;
+ phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
break;
- case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle:
+ case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
// Phase 1: push Matched only to the "real" participant. The session reads
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
// bot's Context fixture preserves the prod-captured cosmetics that previously
@@ -105,27 +109,27 @@ public sealed class BattleSession
from.Context, other.Context,
from.ViewerId, other.ViewerId,
BattleId, ScriptedProfiles.BattleSeed), false));
- Phase = BattleSessionPhase.AwaitingLoaded;
+ phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
break;
- case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
+ case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((from, ScriptedLifecycle.BuildBattleStart(
from.Context, other.Context, from.ViewerId), false));
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
- Phase = BattleSessionPhase.AwaitingSwap;
+ phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
break;
- case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap:
+ case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
{
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
- Phase = BattleSessionPhase.AfterReady;
+ phaseFrom!.Phase = BattleSessionPhase.AfterReady;
break;
}
- case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady:
- case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady:
+ case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
+ case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
// Phase 1: forward the player's TurnEnd to the scripted bot. The bot's
// PushAsync fires its three-frame burst via FrameEmitted; each emitted
// frame loops back through HandleFrameAsync → ComputeFrames → routes to
@@ -141,10 +145,15 @@ public sealed class BattleSession
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
// to the real participant. These match the v1.2 burst's three outbound pushes.
+ // Pre-migration this arm only handled TurnStart/Judge because the handshake
+ // TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
+ // Post-migration that arm gates on the sender's per-participant Phase, which the
+ // bot doesn't have, so the bot's TurnEnd now lands here.
case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A):
+ case NetworkBattleUri.TurnEnd when ReferenceEquals(from, B) || ReferenceEquals(from, A):
case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A):
- // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart
- // and Judge are intended for the real participant; TurnEnd handled above.
+ // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
+ // TurnEnd, and Judge are intended for the real participant.
if (!IsRealForwardableFromScripted(from, env)) goto default;
result.Add((other, env, false));
break;
diff --git a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
index 3a0caed..b43784d 100644
--- a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
+++ b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
@@ -10,6 +10,18 @@ using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Sessions.Participants;
+///
+/// Marker interface implemented by participants that own a handshake-phase cursor.
+/// reads the sender's
+/// when gating the handshake-phase arms (InitNetwork / InitBattle / Loaded / Swap)
+/// and the TurnEnd-AfterReady forwarder. Bots don't implement this — they never
+/// send the gating URIs.
+///
+internal interface IHasHandshakePhase
+{
+ BattleSessionPhase Phase { get; set; }
+}
+
///
/// WS-backed participant. Owns the WS read loop, SIO encoding/decoding, per-WS
/// + . Fires
@@ -17,7 +29,7 @@ namespace SVSim.BattleNode.Sessions.Participants;
/// PushAsync encodes + sends; ordered pushes get a playSeq from the sequencer,
/// no-stock control pushes bypass it.
///
-public sealed class RealParticipant : IBattleParticipant
+public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly WebSocket _ws;
private readonly ILogger _log;
@@ -32,9 +44,17 @@ public sealed class RealParticipant : IBattleParticipant
/// 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.
+ /// because they never send the gating URIs. Also satisfies
+ /// (the interface BattleSession uses to gate
+ /// handshake dispatch without depending on the concrete RealParticipant type).
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
+ BattleSessionPhase IHasHandshakePhase.Phase
+ {
+ get => Phase;
+ set => Phase = value;
+ }
+
public event Func? FrameEmitted;
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index 165d3b6..e6f98a8 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -5,6 +5,7 @@ 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;
@@ -21,7 +22,7 @@ public class BattleSessionDispatchTests
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
Assert.That(routes[0].NoStock, Is.True);
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
}
[Test]
@@ -34,7 +35,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
}
[Test]
@@ -48,7 +49,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Select(r => r.Frame.Uri),
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
}
[Test]
@@ -62,7 +63,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Select(r => r.Frame.Uri),
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
}
[Test]
@@ -78,7 +79,30 @@ public class BattleSessionDispatchTests
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
+ }
+
+ [Test]
+ public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
+ {
+ var a = new FakeRealParticipant(viewerId: 1, FixtureCtx());
+ var b = new FakeRealParticipant(viewerId: 2, FixtureCtx());
+ var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger.Instance);
+
+ // A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
+ b.Phase = BattleSessionPhase.AwaitingInitBattle;
+
+ // A's InitNetwork should ack (matches A's phase).
+ var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
+ Assert.That(routesA.Count, Is.EqualTo(1));
+ Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
+
+ // B's InitBattle should produce Matched (matches B's phase, set above).
+ var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
+ Assert.That(routesB.Count, Is.EqualTo(1));
+ Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
+ Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
}
[Test]
@@ -162,12 +186,12 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
Assert.That(routes, Is.Empty);
- Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
+ Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
}
- private static (BattleSession, FakeParticipant, FakeParticipant) NewSession()
+ private static (BattleSession, FakeRealParticipant, FakeParticipant) NewSession()
{
- var a = new FakeParticipant(viewerId: 1, FixtureCtx());
+ var a = new FakeRealParticipant(viewerId: 1, FixtureCtx());
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext());
var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger.Instance);
return (s, a, b);
@@ -206,4 +230,22 @@ public class BattleSessionDispatchTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private void Touch() => FrameEmitted?.Invoke(null!, default);
}
+
+ /// Like but additionally implements
+ /// so the dispatch tests can drive a participant's
+ /// Phase without instantiating a full RealParticipant (which needs a real
+ /// WebSocket).
+ private sealed class FakeRealParticipant : IBattleParticipant, IHasHandshakePhase
+ {
+ public long ViewerId { get; }
+ public MatchContext Context { get; }
+ public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
+ public event Func? FrameEmitted;
+ public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
+ public Task PushAsync(MsgEnvelope env, 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;
+ private void Touch() => FrameEmitted?.Invoke(null!, default);
+ }
}