diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 805e1fd..6c0d543 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -21,6 +21,14 @@ public sealed class BattleSession { private readonly ILogger _log; + // Mulligan barrier: each side's post-mulligan hand, captured on its Swap. Ready is + // withheld until every IHasHandshakePhase participant has swapped (prod withholds it + // ~3-6 s — see data_dumps/captures/battle-traffic_tk2_*.ndjson). Keyed on the + // IBattleParticipant so the per-side hand is retrievable when releasing both Readys. + // NOTE: mutated from ComputeFrames, which is not lock-guarded — same pre-existing + // single-threaded-dispatch assumption as the Phase mutations (see plan § Out of scope). + private readonly Dictionary _postSwapHands = new(); + public string BattleId { get; } public BattleType Type { get; } public IBattleParticipant A { get; } @@ -224,9 +232,28 @@ public sealed class BattleSession case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap: { 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((from, ScriptedLifecycle.BuildReady(hand), false)); + _postSwapHands[from] = hand; phaseFrom!.Phase = BattleSessionPhase.AfterReady; + + // Release Ready to every swapper once all handshake-driving participants have + // swapped. IHasHandshakePhase membership IS the "participates in mulligan" set: + // PvP → {A, B} (both reals) → waits for both + // Scripted → {player, bot} (bot now emits Swap) → waits for both + // Bot/AINet → {real} only (NoOp isn't a phase impl)→ releases on the one Swap + var swappers = new[] { A, B }.Where(p => p is IHasHandshakePhase).ToList(); + if (swappers.All(_postSwapHands.ContainsKey)) + { + foreach (var p in swappers) + { + var opponent = ReferenceEquals(p, A) ? B : A; + 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)); + } + } break; } diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 031212b..31f417b 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -92,6 +92,7 @@ public class BattleSessionDispatchTests [Test] public void Swap_pushes_SwapResponse_then_Ready_to_sender() { + // Opponent stub is not IHasHandshakePhase → not a barrier swapper → Ready releases immediately. var (s, a, b) = NewSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); @@ -341,7 +342,7 @@ public class BattleSessionDispatchTests } [Test] - public void Pvp_Swap_from_A_pushes_SwapResponse_plus_Ready_to_A_only() + public void Pvp_Swap_from_A_alone_pushes_SwapResponse_only_Ready_withheld() { var (s, a, b) = NewPvpSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); @@ -349,12 +350,37 @@ public class BattleSessionDispatchTests 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."); + Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }), + "Ready is withheld until BOTH sides have mulliganed."); + Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady), + "Phase advances on Swap even though Ready is withheld."); + } + + [Test] + public void Pvp_Swap_from_both_releases_Ready_to_both_with_opponent_hands() + { + var (s, a, b) = NewPvpSession(); + foreach (var p in new[] { a, b }) + { + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded)); + } + + var aRoutes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // first swapper + Assert.That(aRoutes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap })); + + var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // second swapper releases both + // Expect: B's own SwapResponse, then Ready to B, then Ready to A. + Assert.That(bRoutes.Count, Is.EqualTo(3)); + Assert.That(bRoutes[0].Target, Is.SameAs(b)); + Assert.That(bRoutes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Swap)); + + var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready); + var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready); + // Empty mulligans → each hand is the dealt [1,2,3]; oppo mirrors the other side's hand. + Assert.That(((ReadyBody)readyToB.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(((ReadyBody)readyToA.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 })); } [Test] @@ -609,6 +635,7 @@ public class BattleSessionDispatchTests [Test] public void Bot_Swap_per_sender_SwapResponse_plus_Ready() { + // Opponent stub is not IHasHandshakePhase → not a barrier swapper → Ready releases immediately. var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));