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

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

View File

@@ -1,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);
}
}

View File

@@ -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() { }
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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",