Files
SVSimServer/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
gamer147 ba18790156 refactor(battle-node): rename ScriptedLifecycle->ServerBattleFrames, ScriptedProfiles->BattleFrameDefaults
Pure rename. These hold the shared server-authored frame builders used by every
battle mode's handshake/mulligan dispatch — the 'Scripted' name was a historical
accident that hid the PvP/Bot crossover. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:36:32 -04:00

654 lines
28 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 Pvp_Loaded_from_A_assigns_turnState_0()
{
var (s, a, _) = NewPvpSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
var bs = (BattleStartBody)routes[0].Frame.Body;
Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first.");
}
[Test]
public void Pvp_Loaded_from_B_assigns_turnState_1()
{
var (s, _, b) = NewPvpSession();
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
var bs = (BattleStartBody)routes[0].Frame.Body;
Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second.");
}
[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 OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
{
var (s, a, _) = NewPvpSession();
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_alone_pushes_SwapResponse_only_Ready_withheld()
{
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 }),
"Ready is withheld until BOTH sides have mulliganed.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady),
"Phase advances on Swap even though Ready is withheld.");
}
[Test]
public void Pvp_Swap_from_both_releases_Ready_to_both_with_opponent_hands()
{
var (s, a, b) = NewPvpSession();
foreach (var p in new[] { a, b })
{
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded));
}
var aRoutes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // first swapper
Assert.That(aRoutes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }));
var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // second swapper releases both
// Expect: B's own SwapResponse, then Ready to B, then Ready to A.
Assert.That(bRoutes.Count, Is.EqualTo(3));
Assert.That(bRoutes[0].Target, Is.SameAs(b));
Assert.That(bRoutes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Swap));
var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready);
var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready);
// Empty mulligans → each hand is the dealt [1,2,3]; oppo mirrors the other side's hand.
Assert.That(((ReadyBody)readyToB.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
Assert.That(((ReadyBody)readyToA.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
}
[Test]
public void Pvp_TurnStart_from_A_emits_spin0_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnStart));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
var body = (SVSim.BattleNode.Protocol.Bodies.OpponentTurnStartBody)routes[0].Frame.Body;
Assert.That(body.Spin, Is.EqualTo(0));
}
[Test]
public void Pvp_Judge_from_A_reflects_spin0_back_to_sender()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Judge));
// Judge reflects BACK to its sender (the turn taker-over), not to the opponent: receiving
// Judge{spin} fires the sender's ControlTurnStartPlayer. Routing to the opponent would
// restart the just-ended player's turn (2026-06-03 two-client capture).
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));
var body = (SVSim.BattleNode.Protocol.Bodies.JudgeBody)routes[0].Frame.Body;
Assert.That(body.Spin, Is.EqualTo(0));
}
[Test]
public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var body = MoveOrderList(idx: 3, from: 10, to: 20);
body["playIdx"] = 3L;
body["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.PlayIdx, Is.EqualTo(3));
Assert.That(pb.Type, Is.EqualTo(30));
Assert.That(pb.KnownList!.Count, Is.EqualTo(1));
Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3));
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
Assert.That(pb.OppoTargetList, Is.Null);
}
[Test]
public void Pvp_PlayActions_renames_targetList_to_oppoTargetList()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var body = MoveOrderList(idx: 3, from: 10, to: 20);
body["playIdx"] = 3L;
body["type"] = 31L;
body["targetList"] = new List<object?>
{
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
};
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0));
}
[Test]
public void Pvp_PlayActions_token_idx_degrades_to_no_knownList()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var body = MoveOrderList(idx: 31, from: 10, to: 20); // idx 31 > 30-card deck → token
body["playIdx"] = 31L;
body["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(pb.PlayIdx, Is.EqualTo(31));
Assert.That(pb.KnownList, Is.Null);
}
[Test]
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
// B not AfterReady → not BothAfterReady.
var body = MoveOrderList(3, 10, 20);
body["playIdx"] = 3L; body["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
Assert.That(routes, Is.Empty);
}
[Test]
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo));
Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm.");
}
[Test]
public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var body = MoveOrderList(3, 20, 30); // a non-empty orderList that must be dropped
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.TurnEndActions, body));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndActions));
Assert.That(((RawBody)routes[0].Frame.Body).Entries, Is.Empty, "orderList is dropped; body is empty.");
}
[Test]
public void Pvp_JudgeResult_from_A_in_BothAfterReady_forwards_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.JudgeResult));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
}
[Test]
public void Pvp_TurnEnd_from_A_emits_turnState_to_B_only()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
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));
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
Assert.That(body.TurnState, Is.EqualTo(0));
}
[Test]
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
{
// Unified TurnEndFinal handling — A is the winner, B is the loser.
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(3));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
Assert.That(routes[1].Target, Is.SameAs(a));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.LifeWin));
Assert.That(routes[2].Target, Is.SameAs(b));
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Pvp_TurnEnd_when_B_still_AwaitingSwap_drops()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
// B not at AfterReady.
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
Assert.That(routes, Is.Empty);
}
[Test]
public void Pvp_Retire_from_A_pushes_RetireLose_to_A_and_RetireWin_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(2));
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(aRoute.NoStock, Is.True);
Assert.That(bRoute.NoStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Pvp_Kill_from_A_same_as_Retire()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
{
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
var b = new FakeParticipant(viewerId: ServerBattleFrames.FakeOpponentViewerId, NoOpBotContext());
var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger<BattleSession>.Instance);
return (s, a, b);
}
private static MatchContext NoOpBotContext() => new(
SelfDeckCardIds: Array.Empty<long>(),
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
CountryCode: "", UserName: "Bot", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0);
[Test]
public void Bot_InitNetwork_acks_to_sender()
{
var (s, a, _) = NewBotSession();
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(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
}
[Test]
public void Bot_InitBattle_acks_to_sender_with_no_Matched_push()
{
var (s, a, _) = NewBotSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
// Bot InitBattle is ack-only — NO Matched push. Matched would be ignored
// by the client anyway (gated on status == Connect, which is already
// past by the time the wire round-trip completes), but the spec is to
// not send it for clarity.
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
}
[Test]
public void Bot_Loaded_produces_no_routes_but_advances_phase()
{
var (s, a, _) = NewBotSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
// Bot Loaded is silent — no BattleStart, no Deal. Pushing BattleStart
// would actively CORRUPT OppoBattleStartInfo on the client (the wire
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
// placeholder NoOpBotParticipant.Context zeros).
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap),
"Phase still advances even though there are no outbound routes.");
}
[Test]
public void Bot_Swap_per_sender_SwapResponse_plus_Ready()
{
// Opponent stub is not IHasHandshakePhase → not a barrier swapper → Ready releases immediately.
var (s, a, _) = NewBotSession();
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));
}
[Test]
public void Bot_TurnEnd_pushes_Judge_to_sender_only()
{
var (s, a, b) = NewBotSession();
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), "Bot TurnEnd → exactly one Judge frame back.");
Assert.That(routes[0].Target, Is.SameAs(a), "Judge target is the sender, not broadcast.");
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
}
[Test]
public void Bot_TurnEndFinal_pushes_Judge_to_sender_only()
{
var (s, a, _) = NewBotSession();
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.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
Assert.That(routes[0].Target, Is.SameAs(a));
}
[Test]
public void Bot_PlayActions_drops_no_recipient()
{
var (s, a, _) = NewBotSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
// Bot's PlayActions falls through the default arm — the Pvp forwarder is gated
// on Type == Pvp, so Bot's gameplay frames have no routing rule and drop.
// (The PvP semantics would have been "forward to NoOp which swallows" — same
// observable result, but cleaner to leave them as default-drops.)
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
Assert.That(routes, Is.Empty);
}
[Test]
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
{
// Unified Retire/Kill dispatch — same paired push as PvP.
// NoOpBotParticipant swallows its push.
var (s, a, b) = NewBotSession();
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.Retire));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
}
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 void DriveToAfterReady(BattleSession s, FakeRealParticipant p)
{
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Swap));
// p.Phase should now be AfterReady.
}
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 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 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?>()));
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
{
["orderList"] = new List<object?>
{
new Dictionary<string, object?>
{
["move"] = new Dictionary<string, object?>
{
["idx"] = new List<object?> { (long)idx },
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
}
}
}
};
/// <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);
}
}