diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index fe19c48..091b2c2 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -149,12 +149,26 @@ public sealed class BattleSession // TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd. // Post-migration that arm gates on the sender's per-participant Phase, which the // bot doesn't have, so the bot's TurnEnd now lands here. - case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A): - case NetworkBattleUri.TurnEnd when ReferenceEquals(from, B) || ReferenceEquals(from, A): - case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A): + // The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the + // scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without + // it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match + // here and `goto default` would skip the PvP forwarder arm below. + case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env): + case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env): + case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env): // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart, // TurnEnd, and Judge are intended for the real participant. - if (!IsRealForwardableFromScripted(from, env)) goto default; + result.Add((other, env, false)); + break; + + // --- PvP gameplay forwarding (post-AfterReady). + // Order matters: this MUST come after the FakeOpponentViewerId arms so + // Scripted bot emissions don't fall into the PvP forwarder. + case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady(): + case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady(): + case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady(): + case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady(): + case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady(): result.Add((other, env, false)); break; @@ -177,6 +191,13 @@ public sealed class BattleSession return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId; } + // Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed + // the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions + // from one side has no valid recipient. + private bool BothAfterReady() => + (A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady && + (B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady; + private MsgEnvelope BuildAck(NetworkBattleUri uri) => new( uri, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index c628139..2525570 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -260,6 +260,86 @@ public class BattleSessionDispatchTests "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."); + } + private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession() { var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx()); @@ -268,6 +348,15 @@ public class BattleSessionDispatchTests 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",