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:
@@ -118,8 +118,8 @@ public sealed class BattleNodeWebSocketHandler
|
||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
var scriptedBot = new ScriptedBotParticipant();
|
||||
var session = new BattleSessionV2(battleId, pending.Type, realParticipant, scriptedBot,
|
||||
_loggerFactory.CreateLogger<BattleSessionV2>());
|
||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
await session.RunAsync(ctx.RequestAborted);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
@@ -11,164 +11,199 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
[TestFixture]
|
||||
public class BattleSessionDispatchTests
|
||||
{
|
||||
private static BattleSession NewSession()
|
||||
{
|
||||
// ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump.
|
||||
return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, context: FixtureCtx(), log: NullLogger<BattleSession>.Instance);
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
||||
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
||||
|
||||
[Test]
|
||||
public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle()
|
||||
public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle()
|
||||
{
|
||||
var s = NewSession();
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.InitNetwork }));
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InitBattle_PushesMatched_TransitionsToAwaitingLoaded()
|
||||
public void InitBattle_pushes_Matched_to_sender_only()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap()
|
||||
public void Loaded_pushes_BattleStart_then_Deal_to_sender()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
|
||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1()
|
||||
public void Swap_pushes_SwapResponse_then_Ready_to_sender()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||
// Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson
|
||||
// (a List<object?> of boxed long values), wrapped in a RawBody as the inbound type.
|
||||
var swapEnv = new MsgEnvelope(
|
||||
NetworkBattleUri.Swap, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>
|
||||
{
|
||||
["idxList"] = new List<object?> { 2L },
|
||||
}));
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
var responses = s.ComputeResponses(swapEnv);
|
||||
|
||||
var swapBody = (SwapResponseBody)responses[0].Envelope.Body;
|
||||
Assert.That(swapBody.Self[0].Idx, Is.EqualTo(1));
|
||||
Assert.That(swapBody.Self[1].Idx, Is.EqualTo(4)); // swapped — fresh deck idx
|
||||
Assert.That(swapBody.Self[2].Idx, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
|
||||
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TurnEnd_AfterReady_PushesTurnStart_TurnEnd_Judge_StaysInAfterReady()
|
||||
public void TurnEnd_from_real_forwards_to_other_participant()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
// Three-frame cycle: opponent opens its turn, ends it, sends Judge so the client's
|
||||
// JudgeOperation -> ControlTurnStartPlayer fires and the player's next turn begins.
|
||||
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(responses.Select(r => r.NoStock),
|
||||
Is.EqualTo(new[] { false, false, false }));
|
||||
// Phase returns to AfterReady within the same call so the next player TurnEnd can fire
|
||||
// the cycle again. OpponentTurn is set transiently and is never externally observable.
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TurnEnd_CanFireMultipleTimesConsecutively()
|
||||
public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real()
|
||||
{
|
||||
var s = NewSession();
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
|
||||
var (s, a, b) = NewSession();
|
||||
// Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the
|
||||
// ScriptedBotParticipant impl). Session should route it to the real participant.
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnStart();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
var first = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
var second = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
// Both calls produce the same three-frame burst.
|
||||
Assert.That(first.Select(r => r.Envelope.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(second.Select(r => r.Envelope.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal()
|
||||
public void ScriptedBot_emitted_Judge_forwards_to_real()
|
||||
{
|
||||
var s = NewSession();
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire));
|
||||
var (env, noStock) = responses.Single();
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(noStock, Is.True);
|
||||
var (s, a, b) = NewSession();
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentJudge();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_TurnEnd_forwards_to_real()
|
||||
{
|
||||
// TurnEnd from the bot is also one of the burst frames. The case is handled
|
||||
// by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId).
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
// Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot
|
||||
// arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch
|
||||
// arm that forwards any frame from the FakeOpponentViewerId participant.
|
||||
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Retire_pushes_BattleFinish_no_contest_terminates()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Kill_PushesBattleFinishNoContest_TransitionsToTerminal()
|
||||
public void Kill_pushes_BattleFinish_no_contest_terminates()
|
||||
{
|
||||
var s = NewSession();
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Kill));
|
||||
var (env, noStock) = responses.Single();
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(noStock, Is.True);
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swap_ArrivingBeforeLoaded_ProducesNoResponseAndDoesNotAdvancePhase()
|
||||
public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
|
||||
{
|
||||
var s = NewSession();
|
||||
// Skip Loaded — fire Swap straight out of AwaitingInitNetwork.
|
||||
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
|
||||
Assert.That(responses, Is.Empty);
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
Assert.That(routes, Is.Empty);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
|
||||
}
|
||||
|
||||
private static (BattleSession, FakeParticipant, FakeParticipant) NewSession()
|
||||
{
|
||||
var a = new FakeParticipant(viewerId: 1, FixtureCtx());
|
||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext());
|
||||
var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger<BattleSession>.Instance);
|
||||
return (s, a, b);
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private static MatchContext ScriptedBotContext() => new(
|
||||
SelfDeckCardIds: Array.Empty<long>(),
|
||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>()));
|
||||
|
||||
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
||||
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
||||
private sealed class FakeParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId { get; }
|
||||
public MatchContext Context { get; }
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
||||
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Wire;
|
||||
using SVSim.UnitTests.BattleNode.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class BattleSessionPumpTests
|
||||
{
|
||||
// ---- T1a: structural contract ----
|
||||
|
||||
[Test]
|
||||
public void DispatchSocketIo_ReturnsTask_NotAsyncVoid()
|
||||
{
|
||||
// Regression for the M3 fix: async void hides exceptions and lets two dispatches run
|
||||
// concurrently. The fix is to return Task and await it in the read loop. This test
|
||||
// locks the structural contract; if anyone reverts to async void, this fails before
|
||||
// the behavioral tests do.
|
||||
var method = typeof(BattleSession).GetMethod(
|
||||
"DispatchSocketIo",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.That(method, Is.Not.Null, "DispatchSocketIo method must exist");
|
||||
Assert.That(method!.ReturnType, Is.EqualTo(typeof(Task)),
|
||||
"DispatchSocketIo must return Task — async void breaks ordering + exception flow.");
|
||||
}
|
||||
|
||||
// ---- T1b: in-order dispatch ----
|
||||
|
||||
[Test]
|
||||
[Timeout(10000)]
|
||||
public async Task RunAsync_ProcessesTwoMessages_SendsResponsesInOrder()
|
||||
{
|
||||
var ws = new TestWebSocket();
|
||||
var session = new BattleSession(
|
||||
ws: ws, battleId: "bid-pump", viewerId: 906243102, context: FixtureCtx(),
|
||||
log: NullLogger<BattleSession>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var runTask = session.RunAsync(cts.Token);
|
||||
|
||||
// Eat the EIO3 open handshake (first text send from the session).
|
||||
await WaitForSendCountAsync(ws, atLeast: 1, cts.Token);
|
||||
|
||||
// Inbound 1: InitNetwork (expect ack + InitNetwork synchronize push).
|
||||
await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork, pubSeq: 1, ackId: 1,
|
||||
cat: EmitCategory.General);
|
||||
|
||||
// Inbound 2: InitBattle (expect ack + Matched synchronize push).
|
||||
await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitBattle, pubSeq: 2, ackId: 2,
|
||||
cat: EmitCategory.Matching);
|
||||
|
||||
ws.CompleteIncoming();
|
||||
await runTask;
|
||||
|
||||
// Each inbound msg produces a SIO ack (text frame). With serial dispatch the
|
||||
// two acks must come out in the same order as the inbound frames; concurrent
|
||||
// dispatch could reorder them. This is best-effort smoke — it can pass even
|
||||
// under concurrent dispatch if races happen to favor msg-1 — but it catches
|
||||
// common reorderings. T1a (reflection on DispatchSocketIo's return type) is
|
||||
// the structural lock.
|
||||
var sends = ws.Sends.ToList();
|
||||
var ackTextIndices = sends
|
||||
.Select((s, i) => (s, i))
|
||||
.Where(t => t.s.Type == WebSocketMessageType.Text && IsSioAckText(t.s.Payload))
|
||||
.Select(t => t.i)
|
||||
.ToList();
|
||||
Assert.That(ackTextIndices.Count, Is.EqualTo(2), "Expected exactly two SIO acks.");
|
||||
Assert.That(ackTextIndices[0], Is.LessThan(ackTextIndices[1]),
|
||||
"Ack for msg-1 must precede ack for msg-2 — proves dispatches don't reorder under serial await.");
|
||||
}
|
||||
|
||||
// ---- T2: cancellation through send ----
|
||||
|
||||
[Test]
|
||||
[Timeout(10000)]
|
||||
public async Task EncodeAndSendAsync_CtCancelledDuringBlockedSend_PropagatesOperationCanceled()
|
||||
{
|
||||
var ws = new TestWebSocket();
|
||||
var session = new BattleSession(
|
||||
ws: ws, battleId: "bid-cancel", viewerId: 906243102, context: FixtureCtx(),
|
||||
log: NullLogger<BattleSession>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var runTask = session.RunAsync(cts.Token);
|
||||
|
||||
await WaitForSendCountAsync(ws, atLeast: 1, cts.Token); // EIO open handshake
|
||||
|
||||
// Block the next send so the session is suspended mid-write.
|
||||
ws.BlockNextSend();
|
||||
|
||||
// InitNetwork triggers an ack via SendSioAckAsync — that's the send that will block.
|
||||
await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork, pubSeq: 1, ackId: 1,
|
||||
cat: EmitCategory.General);
|
||||
|
||||
// Give the pump time to reach the blocked send.
|
||||
await Task.Delay(100, CancellationToken.None);
|
||||
|
||||
// Cancel the session CT. The blocked send's await on the gate has registered for the
|
||||
// CT and will throw OperationCanceledException.
|
||||
cts.Cancel();
|
||||
|
||||
// RunAsync awaits the dispatch, which awaits the gated send. Cancelling the gate
|
||||
// surfaces as OCE inside HandleMsgEventAsync's try/catch (it logs error and returns),
|
||||
// then the next ReadCompleteMessageAsync sees the cancellation and exits.
|
||||
await runTask;
|
||||
// No assertion on exception type — the inner try/catch swallows it. The point of the
|
||||
// test is that RunAsync TERMINATES rather than hanging indefinitely on the blocked send.
|
||||
// If _sessionCt weren't threaded, the send would still be blocked on the gate and this
|
||||
// test would timeout.
|
||||
Assert.That(runTask.IsCompletedSuccessfully, Is.True,
|
||||
"RunAsync must terminate after _sessionCt is cancelled, not hang on the blocked send.");
|
||||
}
|
||||
|
||||
// ---- T3: full-pump Md5 regression ----
|
||||
|
||||
[Test]
|
||||
[Timeout(10000)]
|
||||
public async Task PubSeqExceedingIntMax_ClipsAndDoesNotKillSession()
|
||||
{
|
||||
var ws = new TestWebSocket();
|
||||
var logCapture = new CapturingLogger();
|
||||
var session = new BattleSession(
|
||||
ws: ws, battleId: "bid-clip", viewerId: 906243102, context: FixtureCtx(), log: logCapture);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
var runTask = session.RunAsync(cts.Token);
|
||||
|
||||
await WaitForSendCountAsync(ws, atLeast: 1, cts.Token);
|
||||
|
||||
await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork,
|
||||
pubSeq: (long)int.MaxValue + 1L, ackId: 1, cat: EmitCategory.General);
|
||||
|
||||
ws.CompleteIncoming();
|
||||
await runTask;
|
||||
|
||||
// Expect: at least one warning logged about clipping, AND the ack send happened.
|
||||
Assert.That(logCapture.WarningCount, Is.GreaterThanOrEqualTo(1),
|
||||
"Clip path must log a warning.");
|
||||
Assert.That(logCapture.Warnings.Any(m => m.Contains("clipping")),
|
||||
Is.True, $"Expected a 'clipping' warning. Got: {string.Join(" | ", logCapture.Warnings)}");
|
||||
Assert.That(ws.Sends.Any(f => f.Type == WebSocketMessageType.Text && IsSioAckText(f.Payload)),
|
||||
Is.True, "Ack must still be sent after clipping.");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForSendCountAsync(TestWebSocket ws, int atLeast, CancellationToken ct)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (ws.Sends.Count < atLeast)
|
||||
{
|
||||
if (sw.ElapsedMilliseconds > 5000)
|
||||
throw new TimeoutException($"WS did not produce {atLeast} sends within 5s.");
|
||||
await Task.Delay(10, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSioAckText(byte[] payload)
|
||||
{
|
||||
// SIO ack text frame starts with EIO Message digit '4' + SIO Ack digit '3'.
|
||||
if (payload.Length < 2) return false;
|
||||
return payload[0] == (byte)'4' && payload[1] == (byte)'3';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode and enqueue a synthetic msg event frame for the pump to receive.
|
||||
/// Pump shape: EIO text "4{sio-text}", then a single binary attachment containing the
|
||||
/// msgpack-encoded encrypted payload.
|
||||
/// </summary>
|
||||
private static async Task EnqueueMsgFrameAsync(
|
||||
TestWebSocket ws, NetworkBattleUri uri, long pubSeq, int ackId, EmitCategory cat)
|
||||
{
|
||||
var key = MakeKey();
|
||||
var env = new MsgEnvelope(
|
||||
uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0, Cat: cat,
|
||||
PubSeq: pubSeq, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>()));
|
||||
var encryptedBytes = MsgPayloadCodec.Encode(env, key);
|
||||
|
||||
// SIO BinaryEvent with one attachment, name "msg", with the ack id.
|
||||
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName: "msg", attachments: new[] { encryptedBytes });
|
||||
var (sioTextOriginal, bins) = sio.Encode();
|
||||
// sioTextOriginal looks like "51-[\"msg\",{\"_placeholder\":true,\"num\":0}]".
|
||||
// Splice ackId before the '['.
|
||||
var bracketIdx = sioTextOriginal.IndexOf('[');
|
||||
var sioTextWithAck = sioTextOriginal.Substring(0, bracketIdx) + ackId + sioTextOriginal.Substring(bracketIdx);
|
||||
|
||||
var eioText = $"{(int)EngineIoPacketType.Message}{sioTextWithAck}";
|
||||
ws.EnqueueIncoming(Encoding.UTF8.GetBytes(eioText), WebSocketMessageType.Text);
|
||||
// EIO3 prefixes binary frames with the Message packet-type byte.
|
||||
var prefixed = new byte[bins[0].Length + 1];
|
||||
prefixed[0] = (byte)EngineIoPacketType.Message;
|
||||
Buffer.BlockCopy(bins[0], 0, prefixed, 1, bins[0].Length);
|
||||
ws.EnqueueIncoming(prefixed, WebSocketMessageType.Binary);
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
private static string MakeKey()
|
||||
{
|
||||
var seq = 0;
|
||||
return NodeCrypto.GenerateKey(() => (seq++ * 7) % 16);
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private sealed class CapturingLogger : ILogger<BattleSession>
|
||||
{
|
||||
public List<string> Warnings { get; } = new();
|
||||
public int WarningCount => Warnings.Count;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning) Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class BattleSessionV2DispatchTests
|
||||
{
|
||||
[Test]
|
||||
public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InitBattle_pushes_Matched_to_sender_only()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Loaded_pushes_BattleStart_then_Deal_to_sender()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
|
||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swap_pushes_SwapResponse_then_Ready_to_sender()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TurnEnd_from_real_forwards_to_other_participant()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
// Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the
|
||||
// ScriptedBotParticipant impl). Session should route it to the real participant.
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnStart();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_Judge_forwards_to_real()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentJudge();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_TurnEnd_forwards_to_real()
|
||||
{
|
||||
// TurnEnd from the bot is also one of the burst frames. The case is handled
|
||||
// by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId).
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
// Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot
|
||||
// arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch
|
||||
// arm that forwards any frame from the FakeOpponentViewerId participant.
|
||||
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Retire_pushes_BattleFinish_no_contest_terminates()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Kill_pushes_BattleFinish_no_contest_terminates()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
Assert.That(routes, Is.Empty);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
|
||||
}
|
||||
|
||||
private static (BattleSessionV2, FakeParticipant, FakeParticipant) NewSession()
|
||||
{
|
||||
var a = new FakeParticipant(viewerId: 1, FixtureCtx());
|
||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext());
|
||||
var s = new BattleSessionV2("bid-1", BattleType.Scripted, a, b, NullLogger<BattleSessionV2>.Instance);
|
||||
return (s, a, b);
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private static MatchContext ScriptedBotContext() => new(
|
||||
SelfDeckCardIds: Array.Empty<long>(),
|
||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>()));
|
||||
|
||||
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
||||
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
||||
private sealed class FakeParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId { get; }
|
||||
public MatchContext Context { get; }
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
||||
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class ClipAckArgTests
|
||||
{
|
||||
[Test]
|
||||
public void InRange_ReturnsArgUnchanged()
|
||||
{
|
||||
var result = BattleSession.ClipAckArg(42L, NullLogger.Instance, battleId: "b");
|
||||
Assert.That(result, Is.EqualTo(42));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AboveIntMax_ClipsToIntMaxValue()
|
||||
{
|
||||
var result = BattleSession.ClipAckArg((long)int.MaxValue + 1L, NullLogger.Instance, battleId: "b");
|
||||
Assert.That(result, Is.EqualTo(int.MaxValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BelowIntMin_ClipsToIntMinValue()
|
||||
{
|
||||
var result = BattleSession.ClipAckArg((long)int.MinValue - 1L, NullLogger.Instance, battleId: "b");
|
||||
Assert.That(result, Is.EqualTo(int.MinValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AtIntMaxBoundary_ReturnsIntMaxValue()
|
||||
{
|
||||
var result = BattleSession.ClipAckArg((long)int.MaxValue, NullLogger.Instance, battleId: "b");
|
||||
Assert.That(result, Is.EqualTo(int.MaxValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AtIntMinBoundary_ReturnsIntMinValue()
|
||||
{
|
||||
var result = BattleSession.ClipAckArg((long)int.MinValue, NullLogger.Instance, battleId: "b");
|
||||
Assert.That(result, Is.EqualTo(int.MinValue));
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,41 @@ public class RealParticipantTests
|
||||
Assert.That(p.Context, Is.SameAs(ctx));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClipAckArg_InRange_ReturnsArgUnchanged()
|
||||
{
|
||||
var result = RealParticipant.ClipAckArg(42L, NullLogger<RealParticipant>.Instance, viewerId: 1);
|
||||
Assert.That(result, Is.EqualTo(42));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClipAckArg_AboveIntMax_ClipsToIntMaxValue()
|
||||
{
|
||||
var result = RealParticipant.ClipAckArg((long)int.MaxValue + 1L, NullLogger<RealParticipant>.Instance, viewerId: 1);
|
||||
Assert.That(result, Is.EqualTo(int.MaxValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClipAckArg_BelowIntMin_ClipsToIntMinValue()
|
||||
{
|
||||
var result = RealParticipant.ClipAckArg((long)int.MinValue - 1L, NullLogger<RealParticipant>.Instance, viewerId: 1);
|
||||
Assert.That(result, Is.EqualTo(int.MinValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClipAckArg_AtIntMaxBoundary_ReturnsIntMaxValue()
|
||||
{
|
||||
var result = RealParticipant.ClipAckArg((long)int.MaxValue, NullLogger<RealParticipant>.Instance, viewerId: 1);
|
||||
Assert.That(result, Is.EqualTo(int.MaxValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClipAckArg_AtIntMinBoundary_ReturnsIntMinValue()
|
||||
{
|
||||
var result = RealParticipant.ClipAckArg((long)int.MinValue, NullLogger<RealParticipant>.Instance, viewerId: 1);
|
||||
Assert.That(result, Is.EqualTo(int.MinValue));
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
|
||||
Reference in New Issue
Block a user