Files
SVSimServer/SVSim.BattleNode/Sessions/BattleSession.cs
gamer147 e30fdb7570 feat(battle-node): scripted opponent turn loop pushes TurnStart + TurnEnd
The TurnEnd/TurnEndFinal case in ComputeResponses now returns two envelopes
back-to-back — opponent TurnStart followed by opponent TurnEnd. Phase enters
OpponentTurn transiently then resets to AfterReady within the same call so
the next player TurnEnd can fire the cycle again. Closes the v1 'stays at
Opponent's turn… forever' stall.
2026-06-01 14:57:49 -04:00

423 lines
19 KiB
C#

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.
/// </summary>
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 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)
{
_ws = ws;
_log = log;
BattleId = battleId;
ViewerId = viewerId;
Context = context;
}
/// <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);
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);
}
}
}
}
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 async Task HandleMsgEventAsync(SocketIoFrame frame)
{
try
{
MsgEnvelope env;
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
catch (Exception ex)
{
_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);
}
}
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);
}
}
/// <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.
/// </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)
{
var result = new List<(MsgEnvelope Envelope, bool NoStock)>();
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));
Phase = BattleSessionPhase.AwaitingInitBattle;
break;
case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle:
result.Add((ScriptedLifecycle.BuildMatched(Context, ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: 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));
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));
Phase = BattleSessionPhase.AfterReady;
break;
}
case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady:
// Scripted opponent: opens its turn, immediately ends it, hands control back.
// Two-frame burst — see docs/superpowers/specs/2026-06-01-battle-node-opponent-turn-loop-design.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));
Phase = BattleSessionPhase.AfterReady;
break;
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
// These always terminate, regardless of phase.
result.Add((BuildBattleFinishNoContest(), NoStock: true));
Phase = BattleSessionPhase.Terminal;
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);
break;
}
return result;
}
private MsgEnvelope BuildAckedEnvelope(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)
{
// 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)
{
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>();
}
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);
}
}