Files
SVSimServer/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
gamer147 8a97dd0194 test(battle-node): PvP handshake dispatch sees cross-perspective contexts
Tests assert that for Type=Pvp, A's InitBattle gets Matched with A's
ctx as selfInfo and B's ctx as oppoInfo, and symmetrically for B. Same
for Loaded/BattleStart. Swap stays per-sender (each runs their own
mulligan).
2026-06-01 21:41:35 -04:00

345 lines
16 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));
}
[Test]
public void Pvp_InitBattle_from_A_pushes_Matched_with_B_oppoInfo_to_A_only()
{
var (s, a, b) = NewPvpSession();
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));
var body = (MatchedBody)routes[0].Frame.Body;
Assert.That(body.SelfInfo.UserName, Is.EqualTo("PlayerA"),
"Matched.selfInfo must reflect the sender (A).");
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(b.ViewerId));
Assert.That(body.OppoInfo.UserName, Is.EqualTo("PlayerB"),
"Matched.oppoInfo must reflect the OTHER participant (B).");
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(a.ViewerId));
Assert.That(body.SelfInfo.Seed, Is.EqualTo(body.OppoInfo.Seed),
"Both sides must see the same seed.");
}
[Test]
public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only()
{
var (s, a, b) = NewPvpSession();
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork));
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
var body = (MatchedBody)routes[0].Frame.Body;
Assert.That(body.SelfInfo.UserName, Is.EqualTo("PlayerB"));
Assert.That(body.OppoInfo.UserName, Is.EqualTo("PlayerA"));
}
[Test]
public void Pvp_Loaded_from_A_pushes_BattleStart_with_B_oppoInfo_plus_Deal_to_A()
{
var (s, a, b) = NewPvpSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.Deal));
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
var bs = (BattleStartBody)routes[0].Frame.Body;
Assert.That(bs.SelfInfo.ClassId, Is.EqualTo("3"), "A is class 3 per fixture.");
Assert.That(bs.OppoInfo.ClassId, Is.EqualTo("5"), "B is class 5 per fixture.");
}
[Test]
public void Pvp_Swap_from_A_pushes_SwapResponse_plus_Ready_to_A_only()
{
var (s, a, b) = NewPvpSession();
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(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork),
"Swap from A doesn't advance B's phase.");
}
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()
{
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
var b = new FakeRealParticipant(viewerId: 2002, PlayerBCtx());
var s = new BattleSession("bid-pvp-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
return (s, a, b);
}
private static MatchContext PlayerACtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "3", CharaId: "3", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "PlayerA", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11);
private static MatchContext PlayerBCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
ClassId: "5", CharaId: "5", CardMasterName: "card_master_node_10015",
CountryCode: "JPN", UserName: "PlayerB", SleeveId: "3000022",
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
BattleType: 11);
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);
}
}