From ee23985055588bc18d906acff3101443bd0e488a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 17:48:20 -0400 Subject: [PATCH] fix(battle-node): push BattleFinish on Scripted TurnEndFinal so the client doesn't park on the disconnect-checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: when the player declared their final winning turn (TurnEndFinal), Scripted mode forwarded it to the bot — which fired a useless 3-frame TurnStart/TurnEnd/Judge burst as if the game were continuing. No BattleFinish was ever pushed, so the client's BattleFinishToOpponentDisConnectChecker (NetworkBattleManagerBase.cs:1640 + BattleFinishToOpponentDisConnectChecker.cs) parked the player on a "waiting for opponent" dialog for 128 seconds, eventually falling through to a synthetic OnDisConnectWin. The user could see "opponent defeated" animations but couldn't proceed to the post-battle screen. After: Scripted TurnEndFinal pushes BattleFinish with result=LifeLose=102 to the player (matches the RESULT_CODE the client expects per NetworkBattleReceiver.cs:963-986; client maps LifeLose → "opponent's life ran out, PLAYER WIN" UI per NetworkBattleManagerBase.cs:1450-1459). Phase transitions to Terminal so RunAsync's PvP-disconnect cascade doesn't synthesize a second BattleFinish on top. No bot burst — the game is over. Wire reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274 shows server pushing TurnEndFinal followed immediately by BattleFinish result:102. BattleResult enum gets the LifeWin=101 / LifeLose=102 values and a corrected docstring. The pre-existing Lose=0 / Win=1 / Consistency=2 values stay (Retire/Kill flow ships them today and works as "no contest" end-of-battle), but their docstring no longer claims they're the WS shape — they were always the HTTP /finish shape, mislabeled. TurnEnd (regular, not final) keeps the existing forward-to-bot behavior in Scripted mode — that's a normal turn boundary, not game end. PvP TurnEndFinal still broadcasts the same TurnEnd+Judge as regular TurnEnd; the actual game-end BattleFinish push in PvP rides the loser's Retire/Kill or the disconnect cascade in RunAsync. 177 battle-node tests passing (was 176; +1 covering the new dispatch arm). Co-Authored-By: Claude Opus 4.7 --- SVSim.BattleNode/Protocol/BattleResult.cs | 52 ++++++++++++++++--- SVSim.BattleNode/Sessions/BattleSession.cs | 39 ++++++++++++-- .../Sessions/BattleSessionDispatchTests.cs | 29 +++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/SVSim.BattleNode/Protocol/BattleResult.cs b/SVSim.BattleNode/Protocol/BattleResult.cs index 1e0bc11..e84ec93 100644 --- a/SVSim.BattleNode/Protocol/BattleResult.cs +++ b/SVSim.BattleNode/Protocol/BattleResult.cs @@ -1,19 +1,55 @@ namespace SVSim.BattleNode.Protocol; /// -/// Wire value of result on a BattleFinish frame. The client's -/// BattleFinishResponsProcessing switch maps these as: -/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch). -/// -/// -/// This is NOT the same as the client's in-memory BATTLE_RESULT_TYPE enum -/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0. +/// Wire value of result on a WS BattleFinish frame. +/// +/// Maps to the client's NetworkBattleReceiver.RESULT_CODE enum at +/// NetworkBattleReceiver.cs:963-986. The client extracts this via +/// NetworkBattleReceiver.cs:1170-1172: +/// +/// case NetworkParameter.result: +/// _receiveData.result = (RESULT_CODE)ConvertToInt(data.Value); +/// +/// then dispatches through NetworkBattleManagerBase.JudgeResultReceive +/// (lines 1412-1488). The wire codes are categorized by HOW the battle ended +/// (life / deckout / retire / disconnect / timeout) and are named from the +/// OPPONENT'S perspective: +/// +/// +/// LifeLose = "opponent's life ran out" → PLAYER WIN UI +/// LifeWin = "opponent's life win condition met" → PLAYER LOSE UI +/// +/// +/// Prior to 2026-06-02 the docstring here claimed the values were 0/1/2 mapping +/// LOSE/WIN/CONSISTENCY — that was the HTTP /finish response shape, not the +/// WS frame. The previous values are kept for backwards compat with the +/// Retire/Kill scripted flow (which has shipped emitting Win = 1, parsed +/// client-side as RESULT_CODE.NoContest and rendered as "battle ended in +/// no contest" — a working-but-not-quite-right scripted-bot terminator). +/// +/// /// Always serialize as the int value, not the name; see the /// JsonNumberEnumConverter on . -/// +/// +/// public enum BattleResult { + /// Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow. + /// Wire-equivalent of RESULT_CODE.NotFinish = 0. Don't use for new code. Lose = 0, + /// Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow. + /// Wire-equivalent of RESULT_CODE.NoContest = 1. Don't use for new code. Win = 1, + /// Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow. + /// Wire-equivalent of RESULT_CODE.Invalid = 2. Don't use for new code. Consistency = 2, + + /// Opponent's life ran out (PLAYER WIN). Pushed by Scripted mode on + /// the player's TurnEndFinal emit. Matches the prod TK2 capture at + /// data_dumps/captures/battle-traffic_tk2_regular.ndjson:274. + LifeLose = 102, + + /// Opponent's life-win condition met (PLAYER LOSE). Not currently emitted + /// by Scripted mode — the bot can't kill the player — but listed for completeness. + LifeWin = 101, } diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index ce9e2d4..d8cecc5 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -220,12 +220,11 @@ public sealed class BattleSession break; } + // Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame + // burst; PvP broadcasts; Bot stays silent. case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady: - case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: if (Type == BattleType.Pvp && BothAfterReady()) { - // Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation -> - // ControlTurnStartPlayer advances the active-player state machine. var turnEndBroadcast = BuildTurnEndBroadcast(); var judgeBroadcast = BuildJudgeBroadcast(); result.Add((from, turnEndBroadcast, false)); @@ -235,10 +234,40 @@ public sealed class BattleSession } else if (Type == BattleType.Scripted) { - // Phase 1 Scripted: forward to bot; bot fires three-frame burst back. result.Add((other, env, false)); } - // For Bot type, no-op (NoOpBot swallows; client handles its own turn end). + // Bot type: no-op (NoOpBot swallows; client handles its own turn end). + break; + + // TurnEndFinal: client signals the player's FINAL turn is over (they hit a + // game-end condition — usually killed opponent's leader). The client computes + // win/loss locally; the server's job is just to push back a wire BattleFinish + // so the client's BattleFinishToOpponentDisConnectChecker doesn't fire. + // Reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274 shows + // TurnEndFinal followed immediately by BattleFinish. + case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: + if (Type == BattleType.Pvp && BothAfterReady()) + { + // PvP: same broadcast as regular TurnEnd. The actual game-end push (a + // separate BattleFinish frame to the survivor) is driven by the loser's + // Retire/Kill or the disconnect cascade in RunAsync — not here. + 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)); + } + else if (Type == BattleType.Scripted) + { + // Push BattleFinish with LifeLose=102 — client interprets as "opponent's + // life ran out, player WIN". DON'T forward to bot (no next turn) and DON'T + // re-fire the 3-frame burst — the game is over. Phase → Terminal so the + // RunAsync cascade doesn't synthesize a follow-up BattleFinish. + result.Add((from, BuildBattleFinish(BattleResult.LifeLose), true)); + Phase = BattleSessionPhase.Terminal; + } + // Bot type: no-op (rank-battle finish is HTTP-driven via /ai_*/finish). break; case NetworkBattleUri.Retire: diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 4f327c9..a56597c 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -82,6 +82,35 @@ public class BattleSessionDispatchTests Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } + [Test] + public void Scripted_TurnEndFinal_pushes_BattleFinish_LifeLose_to_player_and_terminates() + { + // Regression for the BattleFinishToOpponentDisConnectChecker softlock — when the + // player declares their final winning turn (TurnEndFinal), the server must push a + // wire BattleFinish so the client doesn't park on "waiting for opponent" for 128s. + // LifeLose=102 = "opponent's life ran out" → client UI shows player WIN. + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal)); + + Assert.That(routes.Count, Is.EqualTo(1), + "Scripted TurnEndFinal must push exactly one frame (BattleFinish); no bot 3-frame burst."); + Assert.That(routes[0].Target, Is.SameAs(a), + "BattleFinish goes to the player who declared the final turn, not the bot."); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(routes[0].NoStock, Is.True, + "BattleFinish is a no-stock control push (matches the Retire/Kill arm)."); + Assert.That(routes[0].Frame.Body, Is.InstanceOf()); + var body = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body; + Assert.That(body.Result, Is.EqualTo(BattleResult.LifeLose), + "Wire result must be RESULT_CODE.LifeLose (102); the client maps that to player WIN."); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal), + "Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish."); + } + [Test] public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase() {