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;
}