diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 6c0d543..4e9e690 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; +using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Participants; namespace SVSim.BattleNode.Sessions; @@ -134,10 +135,10 @@ public sealed class BattleSession /// . Extracted so unit tests can drive the dispatch without /// standing up real participants. /// - internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames( + internal IReadOnlyList ComputeFrames( IBattleParticipant from, MsgEnvelope env) { - var result = new List<(IBattleParticipant, MsgEnvelope, bool)>(); + var result = new List(); var other = ReferenceEquals(from, A) ? B : A; var phaseFrom = from as IHasHandshakePhase; @@ -148,7 +149,7 @@ public sealed class BattleSession switch (env.Uri) { case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork: - result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true)); + result.Add(new DispatchRoute(from, BuildAck(NetworkBattleUri.InitNetwork), true)); phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle; break; @@ -180,7 +181,7 @@ public sealed class BattleSession case NetworkBattleUri.InitBattle when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle: // Ack only — NO Matched push. - result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true)); + result.Add(new DispatchRoute(from, BuildAck(NetworkBattleUri.InitBattle), true)); phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded; break; @@ -199,7 +200,7 @@ public sealed class BattleSession // Judge to sender ONLY (not broadcast — there's no real other side). // The client's JudgeOperation → ControlTurnStartPlayer flips back to // the local AI's turn after this Judge arrives. - result.Add((from, BuildJudgeBroadcast(), false)); + result.Add(new DispatchRoute(from, BuildJudgeBroadcast(), false)); break; case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle: @@ -207,7 +208,7 @@ public sealed class BattleSession // selfInfo from from.Context and oppoInfo from other.Context (the scripted // bot's Context fixture preserves the prod-captured cosmetics that previously // lived in ScriptedProfiles). - result.Add((from, ScriptedLifecycle.BuildMatched( + result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildMatched( from.Context, other.Context, from.ViewerId, other.ViewerId, BattleId, ScriptedProfiles.BattleSeed), false)); @@ -222,9 +223,9 @@ public sealed class BattleSession // arm (its silent Loaded arm above wins the match). A per-battle coin-flip is a // follow-up (see plan § Out of scope). var turnState = ReferenceEquals(from, A) ? 0 : 1; - result.Add((from, ScriptedLifecycle.BuildBattleStart( + result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildBattleStart( from.Context, other.Context, from.ViewerId, turnState), false)); - result.Add((from, ScriptedLifecycle.BuildDeal(), false)); + result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildDeal(), false)); phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap; break; } @@ -233,7 +234,7 @@ public sealed class BattleSession { var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); // SwapResponse is always immediate — it completes the sender's own mulligan UI. - result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false)); + result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildSwapResponse(hand), false)); _postSwapHands[from] = hand; phaseFrom!.Phase = BattleSessionPhase.AfterReady; @@ -251,7 +252,7 @@ public sealed class BattleSession var ready = opponent is IHasHandshakePhase && _postSwapHands.TryGetValue(opponent, out var oppoHand) ? ScriptedLifecycle.BuildReady(_postSwapHands[p], oppoHand) // both hands known : ScriptedLifecycle.BuildReady(_postSwapHands[p]); // non-interactive opponent - result.Add((p, ready, false)); + result.Add(new DispatchRoute(p, ready, false)); } } break; @@ -264,14 +265,14 @@ public sealed class BattleSession { 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)); + result.Add(new DispatchRoute(from, turnEndBroadcast, false)); + result.Add(new DispatchRoute(other, turnEndBroadcast, false)); + result.Add(new DispatchRoute(from, judgeBroadcast, false)); + result.Add(new DispatchRoute(other, judgeBroadcast, false)); } else if (Type == BattleType.Scripted) { - result.Add((other, env, false)); + result.Add(new DispatchRoute(other, env, false)); } // Bot type: no-op (NoOpBot swallows; client handles its own turn end). break; @@ -286,9 +287,9 @@ public sealed class BattleSession // this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal // so the RunAsync cascade doesn't synthesize a follow-up BattleFinish. case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: - result.Add((other, env, false)); - result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true)); - result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true)); + result.Add(new DispatchRoute(other, env, false)); + result.Add(new DispatchRoute(from, BuildBattleFinish(BattleResult.LifeWin), true)); + result.Add(new DispatchRoute(other, BuildBattleFinish(BattleResult.LifeLose), true)); Phase = BattleSessionPhase.Terminal; break; @@ -297,8 +298,8 @@ public sealed class BattleSession // proper retire codes. Bots swallow their push (no real-opponent state). case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: - result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true)); - result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true)); + result.Add(new DispatchRoute(from, BuildBattleFinish(BattleResult.RetireLose), true)); + result.Add(new DispatchRoute(other, BuildBattleFinish(BattleResult.RetireWin), true)); Phase = BattleSessionPhase.Terminal; break; @@ -317,7 +318,7 @@ public sealed class BattleSession 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. - result.Add((other, env, false)); + result.Add(new DispatchRoute(other, env, false)); break; // Gameplay-frame forwarding (post-AfterReady). Unified across types: @@ -331,7 +332,7 @@ public sealed class BattleSession case NetworkBattleUri.Echo when BothAfterReady(): case NetworkBattleUri.TurnEndActions when BothAfterReady(): case NetworkBattleUri.JudgeResult when BothAfterReady(): - result.Add((other, env, false)); + result.Add(new DispatchRoute(other, env, false)); break; default: diff --git a/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs b/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs new file mode 100644 index 0000000..d3dea72 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs @@ -0,0 +1,8 @@ +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Sessions.Dispatch; + +/// One routing decision: deliver to . +/// Named form of the tuple ComputeFrames historically returned. +/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive. +internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock);