refactor(battle-node): drop old BattleSession; rename V2 -> BattleSession

Old single-WS BattleSession + its dispatch/pump/ClipAckArg tests are
obsolete after the Task 9 handler cutover. ClipAckArg overflow + boundary
coverage moved into RealParticipantTests. BattleSessionV2 renamed back
to BattleSession; the V2 suffix was a placeholder during the parallel
-build refactor.
This commit is contained in:
gamer147
2026-06-01 20:10:14 -04:00
parent 91472df6fc
commit 2d7cee38d3
8 changed files with 282 additions and 1128 deletions

View File

@@ -1,290 +1,170 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Reliability;
using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase.
/// 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 WebSocket _ws;
private readonly ILogger<BattleSession> _log;
/// <summary>
/// Cancellation token for the session lifetime, captured from <see cref="RunAsync"/>'s
/// parameter. Read by all send helpers so host shutdown promptly terminates pending
/// writes (was <c>CancellationToken.None</c> on every send pre-this-slice).
/// </summary>
/// <remarks>
/// Defaults to <see cref="CancellationToken.None"/> when <see cref="RunAsync"/> hasn't
/// run — e.g. unit tests that drive <see cref="ComputeResponses"/> directly without
/// going through the WS pump. None of those tests exercise send code, so the default
/// is safe.
/// </remarks>
private CancellationToken _sessionCt;
public string BattleId { get; }
public long ViewerId { get; }
public BattleType Type { get; }
public IBattleParticipant A { get; }
public IBattleParticipant B { get; }
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
public InboundTracker Inbound { get; } = new();
public OutboundSequencer Outbound { get; } = new();
/// <summary>
/// Player-side snapshot captured at do_matching time. ScriptedLifecycle reads the player
/// half of Matched/BattleStart frames from here; opponent half stays in ScriptedProfiles.
/// </summary>
internal MatchContext Context { get; }
public BattleSession(WebSocket ws, string battleId, long viewerId, MatchContext context, ILogger<BattleSession> log)
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
ILogger<BattleSession> log)
{
_ws = ws;
_log = log;
BattleId = battleId;
ViewerId = viewerId;
Context = context;
Type = type;
A = a;
B = b;
_log = log;
// Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB;
}
/// <summary>
/// Send the EIO3 open handshake then run the read loop until the WS closes.
/// </summary>
public async Task RunAsync(CancellationToken cancellation)
{
_sessionCt = cancellation;
await SendEioOpenAsync(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 */ }
var buffer = new byte[8192];
var pendingAttachments = new List<byte[]>();
SocketIoFrame? pendingFrame = null;
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
{
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
if (msg is null) break;
if (msg.Value.IsText)
{
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation); // EIO3 pong
continue;
}
if (eio.Type != EngineIoPacketType.Message) continue;
// SIO inside the message payload.
var sio = SocketIoFrame.Parse(eio.Payload);
if (sio.AttachmentCount > 0)
{
pendingFrame = sio;
pendingAttachments.Clear();
continue;
}
await DispatchSocketIo(sio);
}
else
{
// Binary frame — an attachment for a pending binary event.
// Engine.IO v3 prefixes binary WS frames with the packet-type byte
// (0x04 = Message), analogous to the leading digit on text frames.
// Strip it before treating the rest as the Socket.IO attachment payload.
var bin = msg.Value.Bytes;
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
{
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
pendingFrame = null;
await DispatchSocketIo(assembled);
}
}
}
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish));
}
private async Task DispatchSocketIo(SocketIoFrame frame)
{
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
{
switch (frame.EventName)
{
case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1:
await HandleMsgEventAsync(frame);
return;
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
await HandleAliveEventAsync(frame);
return;
}
}
// hand / unknown events: log and drop.
_log.LogDebug("BattleSession {Bid}: dropping SIO event={Event}", BattleId, frame.EventName);
}
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 HandleMsgEventAsync(SocketIoFrame frame)
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{
try
{
MsgEnvelope env;
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
catch (Exception ex)
var routes = ComputeFrames(from, env);
foreach (var (target, frame, noStock) in routes)
{
_log.LogWarning(ex, "BattleSession {Bid}: failed to decode msg envelope", BattleId);
return;
}
// Ack tracking + dedupe.
bool shouldDispatch = true;
if (env.PubSeq.HasValue)
{
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
}
}
if (!shouldDispatch) return;
// Run the pure-logic decision and drive sends.
var responses = ComputeResponses(env);
foreach (var (responseEnv, noStock) in responses)
{
if (noStock)
await PushNoStockAsync(responseEnv);
else
await PushOrderedAsync(responseEnv);
await target.PushAsync(frame, noStock, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleMsgEventAsync", BattleId);
}
}
private async Task HandleAliveEventAsync(SocketIoFrame frame)
{
try
{
// Client emits Gungnir every 5s with an SIO ack callback expecting just liveness confirmation
// (payload ignored). We ack immediately, then push our own alive back with scs/ocs ONLINE
// placeholders — the only response the client uses to drive its scs/ocs state machine.
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, 0);
}
var aliveEnv = new MsgEnvelope(
Uri: NetworkBattleUri.Gungnir,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus));
await PushNoStockAsync(aliveEnv, eventName: WireConstants.AliveEvent);
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleAliveEventAsync", BattleId);
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
}
}
/// <summary>
/// Pure-logic lifecycle state machine: given an inbound <see cref="MsgEnvelope"/> and the
/// current <see cref="Phase"/>, return the envelopes the session should push back AND
/// transition <see cref="Phase"/>. Extracted as an internal method so unit tests can drive
/// the state machine without standing up a real WebSocket.
/// 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>
/// <returns>
/// Ordered list of (envelope, no-stock) tuples. <c>NoStock = true</c> means the push is a
/// control frame (ack / BattleFinish) and bypasses <see cref="OutboundSequencer"/>'s
/// playSeq assignment + Resume archive. <c>NoStock = false</c> means the push is part of
/// the ordered stream and gets a fresh playSeq.
/// </returns>
internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env)
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
IBattleParticipant from, MsgEnvelope env)
{
var result = new List<(MsgEnvelope Envelope, bool NoStock)>();
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
var other = ReferenceEquals(from, A) ? B : A;
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches.
switch (env.Uri)
{
// The real handshake sequence (MatchingNetworkConnectChecker + Matching.cs):
// 1. WS opens.
// 2. Client emits InitNetwork (cat=general).
// 3. Server replies with InitNetwork ack → _initNetworkSuccess = true.
// 4. MatchingInitBattle() runs: status=Connect, emits InitBattle, THEN subscribes
// the OnReceivedEvent matching handler.
// 5. Server replies with Matched → handler is subscribed, status=Connect →
// transitions to StartLoad and StartBattleLoad() loads decks/scene.
// 6. Asset load completes → client emits Loaded.
// 7. Server replies with BattleStart + Deal → status=Prepared, GotoBattle().
// Pushing Matched in response to InitNetwork (instead of InitBattle) drops it
// before the handler is subscribed; the state machine never advances.
case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true));
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
Phase = BattleSessionPhase.AwaitingInitBattle;
break;
case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle:
result.Add((ScriptedLifecycle.BuildMatched(Context, ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false));
// Phase 1: push Matched only to the "real" participant. The session reads
// selfInfo from from.Context; opponent half currently comes from
// ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises
// to use other.Context for per-perspective Matched).
result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false));
Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((ScriptedLifecycle.BuildBattleStart(Context, ViewerId), NoStock: false));
result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false));
result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false));
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap:
{
// Compute the actual post-mulligan hand: any idx in idxList that's in the initial
// 3-card hand gets replaced with a fresh deck idx. Both Swap response AND Ready
// need the SAME hand — the client diffs them to compute "drawn cards" and errors
// out with "Card swap failed: AbandonCards[...]/DrawCards[]" if they don't agree.
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
result.Add((ScriptedLifecycle.BuildSwapResponse(hand), NoStock: false));
result.Add((ScriptedLifecycle.BuildReady(hand), NoStock: false));
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
Phase = BattleSessionPhase.AfterReady;
break;
}
case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady:
// Scripted opponent: opens its turn, ends it, then pushes Judge so the
// client's JudgeOperation -> ControlTurnStartPlayer fires and the player's
// next turn begins. Three-frame burst — see
// docs/superpowers/plans/2026-06-01-battle-node-opponent-turn-judge.md.
// OpponentTurn is set then cleared within the same call to document intent;
// the only externally-observable phase after the call is AfterReady, ready for
// the next player TurnEnd to fire the cycle again.
Phase = BattleSessionPhase.OpponentTurn;
result.Add((ScriptedLifecycle.BuildOpponentTurnStart(), NoStock: false));
result.Add((ScriptedLifecycle.BuildOpponentTurnEnd(), NoStock: false));
result.Add((ScriptedLifecycle.BuildOpponentJudge(), NoStock: false));
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:
// These always terminate, regardless of phase.
result.Add((BuildBattleFinishNoContest(), NoStock: true));
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.
case NetworkBattleUri.TurnStart 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.
if (!IsRealForwardableFromScripted(from, env)) goto default;
result.Add((other, env, false));
break;
default:
// Out-of-order or unknown URI: log and drop (no response).
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase}", BattleId, env.Uri, Phase);
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
BattleId, env.Uri, Phase, from.ViewerId);
break;
}
return result;
}
private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new(
// 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,
@@ -308,10 +188,6 @@ public sealed class BattleSession
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{
// Defensive: accept any IEnumerable carrying any numeric boxing (long/int/double/decimal/
// string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk
// anywhere upstream could yield a different boxed type and OfType<long> would silently
// drop the entries — that broke the v1 mulligan during smoke.
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)
{
@@ -331,95 +207,4 @@ public sealed class BattleSession
}
return Array.Empty<long>();
}
private Task PushOrderedAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) =>
EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName);
private Task PushNoStockAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) =>
EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName);
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName)
{
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16));
var bytes = MsgPayloadCodec.Encode(env, key);
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
var (text, bins) = sio.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, _sessionCt);
foreach (var bin in bins)
{
// Engine.IO v3 binary frames are prefixed with the packet-type byte
// (0x04 = Message), the binary analog of the leading digit on text frames.
var prefixed = new byte[bin.Length + 1];
prefixed[0] = (byte)EngineIoPacketType.Message;
Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length);
await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, _sessionCt);
}
}
/// <summary>
/// Clip a long ack arg into the int range Socket.IO v2's typed AckResponse API accepts.
/// Logs a warning on clip; the implausibly-large pubSeq case is observationally
/// indistinguishable at the client (BestHTTP.SocketIO discards the echoed value), so
/// clipping is safer than the prior <c>checked((int)arg)</c> that threw and killed the
/// session on overflow.
/// </summary>
internal static int ClipAckArg(long arg, ILogger log, string battleId)
{
if (arg > int.MaxValue)
{
log.LogWarning(
"BattleSession {Bid}: pubSeq {Seq} exceeds int.MaxValue; clipping ack arg.",
battleId, arg);
return int.MaxValue;
}
if (arg < int.MinValue)
{
log.LogWarning(
"BattleSession {Bid}: pubSeq {Seq} below int.MinValue; clipping ack arg.",
battleId, arg);
return int.MinValue;
}
return (int)arg;
}
private async Task SendSioAckAsync(int ackId, long arg)
{
var ack = SocketIoFrame.AckResponse(ackId, ClipAckArg(arg, _log, BattleId));
var (text, _) = ack.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, _sessionCt);
}
private async Task SendEioOpenAsync(CancellationToken ct)
{
// SID format is server-internal; the EIO3 spec suggests a 20-char base64-url id,
// but BestHTTP's SocketIO client treats it as an opaque string and never validates
// shape. Hex-truncated Guid is fine in practice; revisit if we ever care about
// matching prod telemetry.
var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson();
await SendTextAsync($"0{handshake}", ct);
}
private Task SendTextAsync(string text, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(text);
return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
try { result = await _ws.ReceiveAsync(buffer, ct); }
catch (OperationCanceledException) { return null; }
catch (WebSocketException) { return null; }
if (result.MessageType == WebSocketMessageType.Close) return null;
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
}
}

View File

@@ -1,210 +0,0 @@
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
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 BattleSessionV2
{
private readonly ILogger<BattleSessionV2> _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 BattleSessionV2(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
ILogger<BattleSessionV2> 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, "BattleSessionV2 {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;
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches.
switch (env.Uri)
{
case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
Phase = BattleSessionPhase.AwaitingInitBattle;
break;
case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle:
// Phase 1: push Matched only to the "real" participant. The session reads
// selfInfo from from.Context; opponent half currently comes from
// ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises
// to use other.Context for per-perspective Matched).
result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false));
Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false));
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.Swap when 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;
break;
}
case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when 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.
case NetworkBattleUri.TurnStart 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.
if (!IsRealForwardableFromScripted(from, env)) goto default;
result.Add((other, env, false));
break;
default:
_log.LogDebug("BattleSessionV2 {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>();
}
}