Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.
(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
- forward the envelope to other (matches prod TK2 capture
battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
from server before BattleFinish)
- push BattleFinish(LifeWin) to from (winner)
- push BattleFinish(LifeLose) to other (loser)
- Phase → Terminal
Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
burst on TurnEndFinal (previously it reacted to both TurnEnd and
TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
bot reacting too would race with the BattleFinish push. Bot still
fires on regular TurnEnd as before.
(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
- push BattleFinish(RetireLose=106) to from (the retirer)
- push BattleFinish(RetireWin=105) to other (the survivor)
- Phase → Terminal
Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
same player-perspective convention.
(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.
Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.
Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
assert the bot does NOT fire on TurnEndFinal (was asserting it did).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
748 lines
34 KiB
C#
748 lines
34 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 Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
|
|
{
|
|
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
|
|
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
|
|
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
|
|
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
|
|
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
|
|
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.TurnEndFinal));
|
|
|
|
Assert.That(routes.Count, Is.EqualTo(3),
|
|
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
|
|
|
|
// Route 0: forwarded TurnEndFinal envelope to other.
|
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
|
|
|
|
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
|
|
Assert.That(routes[1].Target, Is.SameAs(a));
|
|
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
Assert.That(routes[1].NoStock, Is.True);
|
|
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
|
|
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
|
|
|
|
// Route 2: BattleFinish(LifeLose) to other (the loser).
|
|
Assert.That(routes[2].Target, Is.SameAs(b));
|
|
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
Assert.That(routes[2].NoStock, Is.True);
|
|
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
|
|
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
|
|
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
|
|
|
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
|
|
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
|
|
}
|
|
|
|
[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_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
|
{
|
|
var (s, a, b) = NewSession();
|
|
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(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
Assert.That(routes[0].NoStock, Is.True);
|
|
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
|
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
|
|
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
|
|
|
|
Assert.That(routes[1].Target, Is.SameAs(b));
|
|
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
Assert.That(routes[1].NoStock, Is.True);
|
|
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
|
|
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
|
|
|
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
|
}
|
|
|
|
[Test]
|
|
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
|
{
|
|
var (s, a, b) = NewSession();
|
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
|
|
|
Assert.That(routes.Count, Is.EqualTo(2));
|
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
|
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
|
|
|
|
Assert.That(routes[1].Target, Is.SameAs(b));
|
|
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
|
|
|
|
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.");
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_TurnStart_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.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));
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_PlayActions_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.PlayActions));
|
|
|
|
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));
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_Echo_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.Echo));
|
|
|
|
Assert.That(routes.Count, Is.EqualTo(1));
|
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_TurnEndActions_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.TurnEndActions));
|
|
|
|
Assert.That(routes.Count, Is.EqualTo(1));
|
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
}
|
|
|
|
[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_PlayActions_when_B_still_AwaitingSwap_drops()
|
|
{
|
|
var (s, a, b) = NewPvpSession();
|
|
DriveToAfterReady(s, a);
|
|
// B is still AwaitingInitNetwork — BothAfterReady is false.
|
|
|
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
|
|
|
Assert.That(routes, Is.Empty,
|
|
"PvP gameplay forwarding must wait until BOTH sides reach AfterReady.");
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
|
|
{
|
|
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(4));
|
|
Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[]
|
|
{
|
|
((IBattleParticipant)a, NetworkBattleUri.TurnEnd),
|
|
((IBattleParticipant)b, NetworkBattleUri.TurnEnd),
|
|
((IBattleParticipant)a, NetworkBattleUri.Judge),
|
|
((IBattleParticipant)b, NetworkBattleUri.Judge),
|
|
}));
|
|
}
|
|
|
|
[Test]
|
|
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
|
|
{
|
|
// Same unified handling as Scripted — 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));
|
|
}
|
|
|
|
[Test]
|
|
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
|
|
{
|
|
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
|
|
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
|
|
// to TurnEnd). The wire-correct codes are still emitted in case future work
|
|
// wants to inspect them or run a real two-real-participant session.
|
|
var (s, a, b) = NewSession();
|
|
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, FakeParticipant) NewBotSession()
|
|
{
|
|
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
|
var b = new FakeParticipant(viewerId: ScriptedLifecycle.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()
|
|
{
|
|
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 Scripted and 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 (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);
|
|
}
|
|
}
|