refactor(battle-node): move handshake phase reads to per-participant

ComputeFrames now reads (from as IHasHandshakePhase)?.Phase for the
four handshake arms (InitNetwork, InitBattle, Loaded, Swap) and the
TurnEnd gate, transitioning the participant's Phase instead of the
session's. RealParticipant implements IHasHandshakePhase via the new
Phase property; the session-level BattleSession.Phase stays for the
Terminal short-circuit.

Scripted dispatch + wire shape unchanged (single-Real-participant case
collapses to Phase 1 semantics). Test fixture migrates FakeParticipant
to FakeRealParticipant for the side that drives handshake states. The
bot's TurnEnd previously rode the session-level AfterReady arm; with
that arm now gated on the sender's per-participant Phase (which the
bot lacks), TurnEnd joins TurnStart/Judge in the scripted-bot
forwarder arm so the v1.2 burst still reaches the real participant.
This commit is contained in:
gamer147
2026-06-01 21:33:17 -04:00
parent ac78473a3e
commit 875a4baa29
3 changed files with 94 additions and 23 deletions

View File

@@ -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<BattleSession>.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<BattleSession>.Instance);
return (s, a, b);
@@ -206,4 +230,22 @@ public class BattleSessionDispatchTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private void Touch() => FrameEmitted?.Invoke(null!, default);
}
/// <summary>Like <see cref="FakeParticipant"/> but additionally implements
/// <see cref="IHasHandshakePhase"/> so the dispatch tests can drive a participant's
/// Phase without instantiating a full <c>RealParticipant</c> (which needs a real
/// WebSocket).</summary>
private sealed class FakeRealParticipant : IBattleParticipant, IHasHandshakePhase
{
public long ViewerId { get; }
public MatchContext Context { get; }
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
public event Func<MsgEnvelope, CancellationToken, Task>? 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);
}
}