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

@@ -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;

View File

@@ -10,6 +10,18 @@ using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Sessions.Participants;
/// <summary>
/// Marker interface implemented by participants that own a handshake-phase cursor.
/// <see cref="BattleSession.ComputeFrames"/> reads the sender's <see cref="Phase"/>
/// 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.
/// </summary>
internal interface IHasHandshakePhase
{
BattleSessionPhase Phase { get; set; }
}
/// <summary>
/// WS-backed participant. Owns the WS read loop, SIO encoding/decoding, per-WS
/// <see cref="OutboundSequencer"/> + <see cref="InboundTracker"/>. 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.
/// </summary>
public sealed class RealParticipant : IBattleParticipant
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly WebSocket _ws;
private readonly ILogger<RealParticipant> _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.</summary>
/// because they never send the gating URIs. Also satisfies
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
BattleSessionPhase IHasHandshakePhase.Phase
{
get => Phase;
set => Phase = value;
}
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,