From 77fb93f3ea2053d605b29f9b18cd092937e7ddda Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 08:30:44 -0400 Subject: [PATCH] fix(battle-node): real mulligan card replacement + opponent TurnStart push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caught during v1 smoke at the mulligan / first-turn boundary: 1) BuildSwapResponse ignored the player's idxList and echoed the same 3-card hand back. The client diffs the new self[] against the Deal to compute "drawn cards" — empty diff against the same hand throws "Card swap failed: AbandonCards[X]/DrawCards[]". Replace swapped idxs with fresh deck idxs (initial hand was 1/2/3, deck has 4..30 still available). Same hand must flow into Ready since the client diffs again there. Move the hand computation into a new helper ComputeHandAfterSwap and have ComputeResponses thread it through both BuildSwapResponse and BuildReady. 2) The client doesn't transition to the "Opponent's turn…" display on its own after sending TurnEnd — it waits for the server to push an opponent TurnStart (per prod TK2 capture line 14). Without it the UI just sits on the end-of-turn frame. Add a TurnEnd handler that pushes a minimal TurnStart{spin} and transitions to a new OpponentTurn phase, which IS the documented v1 stopping point. Co-Authored-By: Claude Opus 4.7 --- .../Lifecycle/ScriptedLifecycle.cs | 78 +++++++++++++------ SVSim.BattleNode/Sessions/BattleSession.cs | 19 ++++- .../Sessions/BattleSessionPhase.cs | 1 + .../Lifecycle/ScriptedLifecycleTests.cs | 45 +++++++++-- .../Sessions/BattleSessionDispatchTests.cs | 13 ++++ 5 files changed, 125 insertions(+), 31 deletions(-) diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 164a348..4d03e50 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -96,45 +96,77 @@ public static class ScriptedLifecycle return EnvelopeForPush(NetworkBattleUri.Deal, body); } - public static MsgEnvelope BuildSwapResponse(IReadOnlyList swapIndices) + /// + /// Initial 3-card hand idxs from . Each position in this array + /// is one card; the value is the card's deck idx. + /// + private static readonly long[] InitialHand = { 1, 2, 3 }; + + /// + /// Compute the player's hand after a mulligan. For every idx in + /// that is currently in the hand, replace it with the next unused deck idx (starting at 4, + /// since 1..3 were dealt). Positions of kept cards are preserved. + /// + public static long[] ComputeHandAfterSwap(IReadOnlyList swapIndices) + { + var hand = (long[])InitialHand.Clone(); + var nextDeckIdx = 4L; + for (var pos = 0; pos < hand.Length; pos++) + { + if (swapIndices.Contains(hand[pos])) + { + hand[pos] = nextDeckIdx++; + } + } + return hand; + } + + public static MsgEnvelope BuildSwapResponse(IReadOnlyList hand) { - // v1: ignore the player's chosen indices and echo the same hand back. - // (Acceptable because the client doesn't validate which idxs come back — it just renders them.) - _ = swapIndices; var body = new Dictionary { - ["self"] = new List - { - new Dictionary { ["pos"] = 0, ["idx"] = 1 }, - new Dictionary { ["pos"] = 1, ["idx"] = 2 }, - new Dictionary { ["pos"] = 2, ["idx"] = 3 }, - }, + ["self"] = BuildPosIdxList(hand), }; return EnvelopeForPush(NetworkBattleUri.Swap, body); } - public static MsgEnvelope BuildReady() + public static MsgEnvelope BuildReady(IReadOnlyList hand) { var body = new Dictionary { - ["self"] = new List - { - new Dictionary { ["pos"] = 0, ["idx"] = 1 }, - new Dictionary { ["pos"] = 1, ["idx"] = 2 }, - new Dictionary { ["pos"] = 2, ["idx"] = 3 }, - }, - ["oppo"] = new List - { - new Dictionary { ["pos"] = 0, ["idx"] = 1 }, - new Dictionary { ["pos"] = 1, ["idx"] = 2 }, - new Dictionary { ["pos"] = 2, ["idx"] = 3 }, - }, + ["self"] = BuildPosIdxList(hand), + // Opponent hand stays at the static 3 cards for v1. + ["oppo"] = BuildPosIdxList(InitialHand), ["idxChangeSeed"] = 771335280, ["spin"] = 243, }; return EnvelopeForPush(NetworkBattleUri.Ready, body); } + /// + /// Generic TurnStart push used to transition the client into "Opponent's turn…" state + /// after the player's TurnEnd. v1 doesn't simulate the opponent — once this lands the + /// client sits at the opponent-turn display indefinitely. + /// + public static MsgEnvelope BuildOpponentTurnStart() + { + var body = new Dictionary + { + ["spin"] = 100, + }; + return EnvelopeForPush(NetworkBattleUri.TurnStart, body); + } + + private static List BuildPosIdxList(IReadOnlyList hand) + { + var list = new List(hand.Count); + for (var pos = 0; pos < hand.Count; pos++) + { + list.Add(new Dictionary { ["pos"] = pos, ["idx"] = (int)hand[pos] }); + } + return list; + } + private static List BuildDummyDeck() { var deck = new List(30); diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index ad7c263..5ccb61c 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -209,10 +209,25 @@ public sealed class BattleSession Phase = BattleSessionPhase.AwaitingSwap; break; case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap: - result.Add((ScriptedLifecycle.BuildSwapResponse(ExtractIdxList(env)), NoStock: false)); - result.Add((ScriptedLifecycle.BuildReady(), NoStock: false)); + { + // Compute the actual post-mulligan hand: any idx in idxList that's in the initial + // 3-card hand gets replaced with a fresh deck idx. Both Swap response AND Ready + // need the SAME hand — the client diffs them to compute "drawn cards" and errors + // out with "Card swap failed: AbandonCards[...]/DrawCards[]" if they don't agree. + var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); + result.Add((ScriptedLifecycle.BuildSwapResponse(hand), NoStock: false)); + result.Add((ScriptedLifecycle.BuildReady(hand), NoStock: false)); Phase = BattleSessionPhase.AfterReady; break; + } + case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady: + case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady: + // Push a minimal opponent TurnStart so the client transitions to "Opponent's turn…" + // display. v1 doesn't simulate the opponent — once this lands, the client sits + // there indefinitely (this IS the documented v1 stopping point). + result.Add((ScriptedLifecycle.BuildOpponentTurnStart(), NoStock: false)); + Phase = BattleSessionPhase.OpponentTurn; + break; case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: // These always terminate, regardless of phase. diff --git a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs index 3e33b6d..4bfb3ea 100644 --- a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs +++ b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs @@ -11,5 +11,6 @@ public enum BattleSessionPhase AwaitingLoaded, AwaitingSwap, AfterReady, + OpponentTurn, Terminal, } diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs index de8707a..f9ca3d9 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -50,18 +50,51 @@ public class ScriptedLifecycleTests } [Test] - public void BuildSwapResponse_EchoesSameHandIfNoSwap() + public void ComputeHandAfterSwap_NoSwap_ReturnsInitialHand() { - var env = ScriptedLifecycle.BuildSwapResponse(swapIndices: Array.Empty()); - var self = (List)env.Body["self"]!; - Assert.That(self.Count, Is.EqualTo(3)); + var hand = ScriptedLifecycle.ComputeHandAfterSwap(Array.Empty()); + Assert.That(hand, Is.EqualTo(new long[] { 1, 2, 3 })); } [Test] - public void BuildReady_IncludesIdxChangeSeedAndSpin() + public void ComputeHandAfterSwap_SwapMiddleCard_ReplacesWithFreshDeckIdx() { - var env = ScriptedLifecycle.BuildReady(); + var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 2 }); + // pos 0 keeps idx 1; pos 1 (was idx 2) gets next deck idx (4); pos 2 keeps idx 3. + Assert.That(hand, Is.EqualTo(new long[] { 1, 4, 3 })); + } + + [Test] + public void ComputeHandAfterSwap_SwapAll_ReplacesAllWithFreshDeckIdxs() + { + var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 1, 2, 3 }); + Assert.That(hand, Is.EqualTo(new long[] { 4, 5, 6 })); + } + + [Test] + public void BuildSwapResponse_RendersGivenHandAsPositions() + { + var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 }); + var self = (List)env.Body["self"]!; + Assert.That(self.Count, Is.EqualTo(3)); + Assert.That(((Dictionary)self[1]!)["idx"], Is.EqualTo(4)); + } + + [Test] + public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand() + { + var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 }); Assert.That(env.Body.ContainsKey("idxChangeSeed"), Is.True); Assert.That(env.Body.ContainsKey("spin"), Is.True); + var self = (List)env.Body["self"]!; + Assert.That(((Dictionary)self[1]!)["idx"], Is.EqualTo(4)); + } + + [Test] + public void BuildOpponentTurnStart_HasUriTurnStartAndSpin() + { + var env = ScriptedLifecycle.BuildOpponentTurnStart(); + Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); + Assert.That(env.Body.ContainsKey("spin"), Is.True); } } diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 8d4292c..a33900c 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -64,6 +64,19 @@ public class BattleSessionDispatchTests Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } + [Test] + public void TurnEnd_AfterReady_PushesOpponentTurnStart_TransitionsToOpponentTurn() + { + var s = NewSession(); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd)); + Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.OpponentTurn)); + } + [Test] public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal() {