fix(battle-node): type-agnostic mulligan barrier withholds Ready until both swap

Ready was sent per-side immediately carrying the placeholder opponent hand, so
one client cleared mulligan before the other. The barrier now releases Ready to
every IHasHandshakePhase participant only once all have swapped, each carrying
the opponent's real post-mulligan hand. No Type check — NoOp (Bot/AINetwork)
isn't a phase impl, so that mode still releases immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-03 10:52:33 -04:00
parent 8052ed60ec
commit 2d31037648
2 changed files with 62 additions and 8 deletions

View File

@@ -21,6 +21,14 @@ public sealed class BattleSession
{
private readonly ILogger<BattleSession> _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<IBattleParticipant, long[]> _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;
}

View File

@@ -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));