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