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.
224 lines
10 KiB
C#
224 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// v2 broker session. Holds two participants and brokers between them. Subscribes
|
|
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
|
|
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
|
|
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
|
|
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
|
|
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
|
|
/// </remarks>
|
|
public sealed class BattleSession
|
|
{
|
|
private readonly ILogger<BattleSession> _log;
|
|
|
|
public string BattleId { get; }
|
|
public BattleType Type { get; }
|
|
public IBattleParticipant A { get; }
|
|
public IBattleParticipant B { get; }
|
|
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
|
|
|
|
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
|
ILogger<BattleSession> log)
|
|
{
|
|
BattleId = battleId;
|
|
Type = type;
|
|
A = a;
|
|
B = b;
|
|
_log = log;
|
|
|
|
// Subscribe to both participants' emissions.
|
|
A.FrameEmitted += OnFrameFromA;
|
|
B.FrameEmitted += OnFrameFromB;
|
|
}
|
|
|
|
public async Task RunAsync(CancellationToken cancellation)
|
|
{
|
|
// Run both participants' inbound loops in parallel and wait for them all to
|
|
// complete. NoOp/Scripted bots return immediately; Real returns when the WS
|
|
// closes. Using WhenAny here would have killed the session as soon as the
|
|
// scripted bot's no-op RunAsync resolved. Phase 2's Pvp/Bot cases will need
|
|
// disconnect propagation; that's wired in their own task.
|
|
var aTask = A.RunAsync(cancellation);
|
|
var bTask = B.RunAsync(cancellation);
|
|
try { await Task.WhenAll(aTask, bTask); } catch { /* swallow cancellation */ }
|
|
|
|
await Task.WhenAll(
|
|
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
|
B.TerminateAsync(BattleFinishReason.NormalFinish));
|
|
}
|
|
|
|
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
|
private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct);
|
|
|
|
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var routes = ComputeFrames(from, env);
|
|
foreach (var (target, frame, noStock) in routes)
|
|
{
|
|
await target.PushAsync(frame, noStock, ct);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
|
|
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
|
|
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
|
/// standing up real participants.
|
|
/// </summary>
|
|
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
|
|
IBattleParticipant from, MsgEnvelope env)
|
|
{
|
|
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. 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 phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
|
|
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
|
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
|
break;
|
|
|
|
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
|
|
// lived in ScriptedProfiles).
|
|
result.Add((from, ScriptedLifecycle.BuildMatched(
|
|
from.Context, other.Context,
|
|
from.ViewerId, other.ViewerId,
|
|
BattleId, ScriptedProfiles.BattleSeed), false));
|
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
|
break;
|
|
|
|
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));
|
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
|
break;
|
|
|
|
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));
|
|
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
|
|
break;
|
|
}
|
|
|
|
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
|
|
// the real participant. Net wire effect: same three pushes as v1.2.
|
|
result.Add((other, env, false));
|
|
break;
|
|
|
|
case NetworkBattleUri.Retire:
|
|
case NetworkBattleUri.Kill:
|
|
result.Add((from, BuildBattleFinishNoContest(), true));
|
|
Phase = BattleSessionPhase.Terminal;
|
|
break;
|
|
|
|
// 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,
|
|
// TurnEnd, and Judge are intended for the real participant.
|
|
if (!IsRealForwardableFromScripted(from, env)) goto default;
|
|
result.Add((other, env, false));
|
|
break;
|
|
|
|
default:
|
|
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
|
BattleId, env.Uri, Phase, from.ViewerId);
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
|
|
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
|
|
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
|
|
// above only fire when the source is actually a participant (not malformed inbound).
|
|
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
|
|
{
|
|
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
|
|
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
|
|
}
|
|
|
|
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
|
uri,
|
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
Uuid: WireConstants.ServerUuid,
|
|
Bid: null,
|
|
Try: 0,
|
|
Cat: EmitCategory.General,
|
|
PubSeq: null,
|
|
PlaySeq: null,
|
|
Body: new ResultCodeOnlyBody());
|
|
|
|
private MsgEnvelope BuildBattleFinishNoContest() => new(
|
|
NetworkBattleUri.BattleFinish,
|
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
Uuid: WireConstants.ServerUuid,
|
|
Bid: null,
|
|
Try: 0,
|
|
Cat: EmitCategory.Battle,
|
|
PubSeq: null,
|
|
PlaySeq: null,
|
|
Body: new BattleFinishBody(Result: BattleResult.Win));
|
|
|
|
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
|
{
|
|
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
|
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
|
{
|
|
var result = new List<long>();
|
|
foreach (var item in seq)
|
|
{
|
|
switch (item)
|
|
{
|
|
case long l: result.Add(l); break;
|
|
case int i: result.Add(i); break;
|
|
case double d: result.Add((long)d); break;
|
|
case decimal m: result.Add((long)m); break;
|
|
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
return Array.Empty<long>();
|
|
}
|
|
}
|