diff --git a/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs b/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs index 1d8a163..8fe1a59 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs @@ -26,9 +26,16 @@ internal sealed class FrameDispatchContext set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; } } - /// Both participants have completed the handshake. Reads A/B (not From/Other) so the - /// result is identical regardless of which side sent the frame — matches legacy BothAfterReady. - internal bool BothAfterReady() => + /// Just the SENDER has finished the handshake — says nothing about the opponent. The + /// Bot arms gate on this (the bot has no handshake phase of its own); contrast + /// , which the PvP arms require. The sender-only vs both-sides + /// distinction is load-bearing for the Bot/PvP split (see TurnEndHandler / TurnEndFinalHandler). + internal bool SenderIsAfterReady => SenderPhase == HandshakePhase.AfterReady; + + /// BOTH participants have finished the handshake. Reads A/B (not From/Other) so the + /// result is identical regardless of which side sent the frame. Contrast + /// (sender only). + internal bool BothSidesAfterReady() => (A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady && (B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady; } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs index 8efbb50..4aa4da3 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs @@ -13,7 +13,7 @@ internal sealed class EchoHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) { var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault(WireKeys.OrderList); ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs index 761132e..f62b413 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs @@ -6,7 +6,7 @@ internal sealed class ForwardWhenBothReadyHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - if (ctx.BothAfterReady()) + if (ctx.BothSidesAfterReady()) return new[] { new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal) }; return Array.Empty(); } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs index 2ec216d..b8a92a1 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs @@ -15,7 +15,7 @@ internal sealed class JudgeHandler : IFrameHandler // start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture). // The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}. // battleCode is dropped; spin=0 for the deterministic-turn slice. - if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) { var frame = ctx.Env with { Body = new JudgeBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) }; return new[] { new DispatchRoute(ctx.From, frame, Stock.Normal) }; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 62180f1..58e6e3c 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -14,7 +14,7 @@ internal sealed class PlayActionsHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady()) + if (ctx.Type != BattleType.Pvp || !ctx.BothSidesAfterReady()) return Array.Empty(); var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary(); diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs index 1ff09f7..f685299 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs @@ -9,7 +9,7 @@ internal sealed class TurnEndActionsHandler : IFrameHandler { public IReadOnlyList Handle(FrameDispatchContext ctx) { - if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) { var frame = ctx.Env with { Body = new RawBody(new Dictionary()) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs index d02db46..aadaccd 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs @@ -7,11 +7,11 @@ internal sealed class TurnEndFinalHandler : IFrameHandler public IReadOnlyList Handle(FrameDispatchContext ctx) { // case 4: Bot — Judge to sender only. - if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AfterReady) + if (ctx.Type == BattleType.Bot && ctx.SenderIsAfterReady) return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) }; // case 9: general — forward the envelope to other + paired BattleFinish + Terminal. - if (ctx.SenderPhase == HandshakePhase.AfterReady) + if (ctx.SenderIsAfterReady) { ctx.State.Lifecycle = SessionLifecycle.Terminal; // Polarity: the SENDER dealt the lethal, so From WINS / Other LOSES. This is the diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs index 3da849e..e33bc11 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs @@ -8,14 +8,14 @@ internal sealed class TurnEndHandler : IFrameHandler public IReadOnlyList Handle(FrameDispatchContext ctx) { // case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI). - if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AfterReady) + if (ctx.Type == BattleType.Bot && ctx.SenderIsAfterReady) return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) }; // case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent // (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame. - if (ctx.SenderPhase == HandshakePhase.AfterReady) + if (ctx.SenderIsAfterReady) { - if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) { // Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate): // the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs index 798d157..ef009d3 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs @@ -10,7 +10,7 @@ internal sealed class TurnStartHandler : IFrameHandler { // PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin} // (spin=0 for the deterministic-turn slice) and self-generates its turn-open. - if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) + if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) { var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index e69fca9..d9cdd37 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -711,7 +711,7 @@ public class BattleSessionDispatchTests { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); - // B not AfterReady → not BothAfterReady. + // B not AfterReady → not BothSidesAfterReady. var body = MoveOrderList(3, 10, 20); body["playIdx"] = 3L; body["type"] = 30L; var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); @@ -719,7 +719,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed() + public void Pvp_Echo_from_A_in_BothSidesAfterReady_is_consumed_not_relayed() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a); @@ -782,7 +782,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_JudgeResult_from_A_in_BothAfterReady_forwards_to_B() + public void Pvp_JudgeResult_from_A_in_BothSidesAfterReady_forwards_to_B() { var (s, a, b) = NewPvpSession(); DriveToAfterReady(s, a);