ComputeFrames now reads (from as IHasHandshakePhase)?.Phase for the four handshake arms (InitNetwork, InitBattle, Loaded, Swap) and the TurnEnd gate, transitioning the participant's Phase instead of the session's. RealParticipant implements IHasHandshakePhase via the new Phase property; the session-level BattleSession.Phase stays for the Terminal short-circuit. Scripted dispatch + wire shape unchanged (single-Real-participant case collapses to Phase 1 semantics). Test fixture migrates FakeParticipant to FakeRealParticipant for the side that drives handshake states. The bot's TurnEnd previously rode the session-level AfterReady arm; with that arm now gated on the sender's per-participant Phase (which the bot lacks), TurnEnd joins TurnStart/Judge in the scripted-bot forwarder arm so the v1.2 burst still reaches the real participant.
252 lines
12 KiB
C#
252 lines
12 KiB
C#
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;
|
|
using SVSim.BattleNode.Sessions.Participants;
|
|
|
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
|
|
|
[TestFixture]
|
|
public class BattleSessionDispatchTests
|
|
{
|
|
[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(a.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(a.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(a.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(a.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(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
|
}
|
|
|
|
[Test]
|
|
public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
|
|
{
|
|
var a = new FakeRealParticipant(viewerId: 1, FixtureCtx());
|
|
var b = new FakeRealParticipant(viewerId: 2, FixtureCtx());
|
|
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
|
|
|
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
|
|
b.Phase = BattleSessionPhase.AwaitingInitBattle;
|
|
|
|
// A's InitNetwork should ack (matches A's phase).
|
|
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
Assert.That(routesA.Count, Is.EqualTo(1));
|
|
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
|
|
|
// B's InitBattle should produce Matched (matches B's phase, set above).
|
|
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
|
Assert.That(routesB.Count, Is.EqualTo(1));
|
|
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
|
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
|
}
|
|
|
|
[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(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
|
|
}
|
|
|
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewSession()
|
|
{
|
|
var a = new FakeRealParticipant(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);
|
|
}
|
|
|
|
/// <summary>Like <see cref="FakeParticipant"/> but additionally implements
|
|
/// <see cref="IHasHandshakePhase"/> so the dispatch tests can drive a participant's
|
|
/// Phase without instantiating a full <c>RealParticipant</c> (which needs a real
|
|
/// WebSocket).</summary>
|
|
private sealed class FakeRealParticipant : IBattleParticipant, IHasHandshakePhase
|
|
{
|
|
public long ViewerId { get; }
|
|
public MatchContext Context { get; }
|
|
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
|
public FakeRealParticipant(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);
|
|
}
|
|
}
|