diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 091b2c2..a9ba7ff 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -130,16 +130,37 @@ public sealed class BattleSession case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady: case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: - // Phase 1: forward the player's TurnEnd to the scripted bot. The bot's - // PushAsync fires its three-frame burst via FrameEmitted; each emitted - // frame loops back through HandleFrameAsync → ComputeFrames → routes to - // the real participant. Net wire effect: same three pushes as v1.2. - result.Add((other, env, false)); + if (Type == BattleType.Pvp && BothAfterReady()) + { + // Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation -> + // ControlTurnStartPlayer advances the active-player state machine. + var turnEndBroadcast = BuildTurnEndBroadcast(); + var judgeBroadcast = BuildJudgeBroadcast(); + result.Add((from, turnEndBroadcast, false)); + result.Add((other, turnEndBroadcast, false)); + result.Add((from, judgeBroadcast, false)); + result.Add((other, judgeBroadcast, false)); + } + else if (Type == BattleType.Scripted) + { + // Phase 1 Scripted: forward to bot; bot fires three-frame burst back. + result.Add((other, env, false)); + } + // For Bot type, no-op (NoOpBot swallows; client handles its own turn end). break; case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: - result.Add((from, BuildBattleFinishNoContest(), true)); + if (Type == BattleType.Pvp) + { + result.Add((from, BuildBattleFinish(BattleResult.Lose), true)); + result.Add((other, BuildBattleFinish(BattleResult.Win), true)); + } + else + { + // Scripted (and future Bot) — sender wins by default (no real opponent). + result.Add((from, BuildBattleFinishNoContest(), true)); + } Phase = BattleSessionPhase.Terminal; break; @@ -220,6 +241,39 @@ public sealed class BattleSession PlaySeq: null, Body: new BattleFinishBody(Result: BattleResult.Win)); + private MsgEnvelope BuildTurnEndBroadcast() => new( + NetworkBattleUri.TurnEnd, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: WireConstants.ServerUuid, + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new TurnEndBody(TurnState: 0)); + + private MsgEnvelope BuildJudgeBroadcast() => new( + NetworkBattleUri.Judge, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: WireConstants.ServerUuid, + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin)); + + private MsgEnvelope BuildBattleFinish(BattleResult result) => new( + NetworkBattleUri.BattleFinish, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: WireConstants.ServerUuid, + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new BattleFinishBody(Result: result)); + private static IReadOnlyList ExtractIdxList(MsgEnvelope env) { if (env.Body is not RawBody rawBody) return Array.Empty(); diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 2525570..e6b5858 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -340,6 +340,97 @@ public class BattleSessionDispatchTests "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_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.TurnEndFinal)); + + Assert.That(routes.Count, Is.EqualTo(4)); + Assert.That(routes.Select(r => r.Frame.Uri).Distinct(), + Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); + } + + [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_BattleFinish_Lose_to_A_and_Win_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.Lose)); + Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win)); + 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_still_pushes_BattleFinish_Win_to_sender_only() + { + // Regression guard — Phase 1 behavior preserved for Scripted. + 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(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win)); + } + private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession() { var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());