Pushing Matched in response to InitNetwork lands it before
MatchingInitBattle() finishes wiring up the OnReceivedEvent handler
and setting status=Connect. The client's Matched-case in
ReactionReceiveUri only transitions to StartLoad when status is
Connect at the moment of receipt; otherwise the frame is silently
dropped at the state machine and the matchmaking UI never advances.
The real connect-handshake sequence (per MatchingNetworkConnectChecker
+ Matching.cs):
1. WS opens.
2. Client emits InitNetwork (cat=general).
3. Server replies InitNetwork ack → _initNetworkSuccess = true.
4. MatchingInitBattle: status=Connect; emit InitBattle; subscribe
OnReceivedEvent matching handler.
5. Server replies Matched → status=StartLoad, StartBattleLoad.
6. Asset load done → client emits Loaded.
7. Server replies BattleStart + Deal → status=Prepared, GotoBattle.
Add AwaitingInitBattle phase, gate Matched on InitBattle receipt, and
gate BattleStart+Deal on Loaded receipt. Update dispatch and
integration tests to walk the new sequence; InitBattle's wire cat is
Matching(2), not Battle(1).
Caught during v1 smoke walkthrough — battle-traffic.ndjson showed the
client receiving Matched/BattleStart at sub-millisecond gaps after
InitNetwork ack, but never advancing past matchmaking.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
321 lines
13 KiB
C#
321 lines
13 KiB
C#
using System.Net.WebSockets;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging;
|
|
using SVSim.BattleNode.Lifecycle;
|
|
using SVSim.BattleNode.Protocol;
|
|
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;
|
|
|
|
public string BattleId { get; }
|
|
public long ViewerId { get; }
|
|
public BattleSessionPhase Phase { get; internal set; } = BattleSessionPhase.AwaitingInitNetwork;
|
|
public InboundTracker Inbound { get; } = new();
|
|
public OutboundSequencer Outbound { get; } = new();
|
|
|
|
public BattleSession(WebSocket ws, string battleId, long viewerId, ILogger<BattleSession> log)
|
|
{
|
|
_ws = ws;
|
|
_log = log;
|
|
BattleId = battleId;
|
|
ViewerId = viewerId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send the EIO3 open handshake then run the read loop until the WS closes.
|
|
/// </summary>
|
|
public async Task RunAsync(CancellationToken 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;
|
|
}
|
|
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;
|
|
DispatchSocketIo(assembled);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async void DispatchSocketIo(SocketIoFrame frame)
|
|
{
|
|
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
|
|
{
|
|
switch (frame.EventName)
|
|
{
|
|
case "msg" when frame.BinaryAttachments.Count == 1:
|
|
await HandleMsgEventAsync(frame);
|
|
return;
|
|
case "alive" 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: "node-stub",
|
|
Bid: null,
|
|
Try: 0,
|
|
Cat: EmitCategory.General,
|
|
PubSeq: null,
|
|
PlaySeq: null,
|
|
Body: Gungnir.BuildAlivePushBody());
|
|
await PushNoStockAsync(aliveEnv, eventName: "alive");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleAliveEventAsync", BattleId);
|
|
}
|
|
}
|
|
|
|
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(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false));
|
|
Phase = BattleSessionPhase.AwaitingLoaded;
|
|
break;
|
|
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
|
|
result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false));
|
|
result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false));
|
|
Phase = BattleSessionPhase.AwaitingSwap;
|
|
break;
|
|
case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap:
|
|
result.Add((ScriptedLifecycle.BuildSwapResponse(ExtractIdxList(env)), NoStock: false));
|
|
result.Add((ScriptedLifecycle.BuildReady(), 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: "node-stub",
|
|
Bid: null,
|
|
Try: 0,
|
|
Cat: EmitCategory.General,
|
|
PubSeq: null,
|
|
PlaySeq: null,
|
|
Body: new Dictionary<string, object?> { ["resultCode"] = 1 });
|
|
|
|
private MsgEnvelope BuildBattleFinishNoContest() => new(
|
|
NetworkBattleUri.BattleFinish,
|
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
Uuid: "node-stub",
|
|
Bid: null,
|
|
Try: 0,
|
|
Cat: EmitCategory.Battle,
|
|
PubSeq: null,
|
|
PlaySeq: null,
|
|
Body: new Dictionary<string, object?> { ["result"] = 1, ["resultCode"] = 1 });
|
|
|
|
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
|
{
|
|
if (env.Body.TryGetValue("idxList", out var raw) && raw is List<object?> lst)
|
|
return lst.OfType<long>().ToList();
|
|
return Array.Empty<long>();
|
|
}
|
|
|
|
private Task PushOrderedAsync(MsgEnvelope env, string eventName = "synchronize") =>
|
|
EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName);
|
|
|
|
private Task PushNoStockAsync(MsgEnvelope env, string eventName = "synchronize") =>
|
|
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, CancellationToken.None);
|
|
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, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private async Task SendSioAckAsync(int ackId, long arg)
|
|
{
|
|
// checked() ensures we'd notice if pubSeq ever exceeded int.MaxValue (not realistic but defensive).
|
|
var ack = SocketIoFrame.AckResponse(ackId, checked((int)arg));
|
|
var (text, _) = ack.Encode();
|
|
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
|
|
await SendTextAsync(eioText, CancellationToken.None);
|
|
}
|
|
|
|
private async Task SendEioOpenAsync(CancellationToken ct)
|
|
{
|
|
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);
|
|
}
|
|
}
|