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;
///
/// v2 broker session. Holds two participants and brokers between them. Subscribes
/// to each participant's ; on each frame,
/// runs to determine the routing (target + frame + noStock
/// flag) and dispatches via .
///
///
/// Phase 1 wires this for 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).
///
public sealed class BattleSession
{
private readonly ILogger _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 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);
}
}
///
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
/// . Extracted so unit tests can drive the dispatch without
/// standing up real participants.
///
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 ExtractIdxList(MsgEnvelope env)
{
if (env.Body is not RawBody rawBody) return Array.Empty();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{
var result = new List();
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();
}
}