fix(battle-node): push BattleFinish on Scripted TurnEndFinal so the client doesn't park on the disconnect-checker
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,55 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
|
||||
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
|
||||
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
|
||||
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
|
||||
/// Wire value of <c>result</c> on a WS <c>BattleFinish</c> frame.
|
||||
/// <para>
|
||||
/// Maps to the client's <c>NetworkBattleReceiver.RESULT_CODE</c> enum at
|
||||
/// <c>NetworkBattleReceiver.cs:963-986</c>. The client extracts this via
|
||||
/// <c>NetworkBattleReceiver.cs:1170-1172</c>:
|
||||
/// <code>
|
||||
/// case NetworkParameter.result:
|
||||
/// _receiveData.result = (RESULT_CODE)ConvertToInt(data.Value);
|
||||
/// </code>
|
||||
/// then dispatches through <c>NetworkBattleManagerBase.JudgeResultReceive</c>
|
||||
/// (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:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>LifeLose</c> = "opponent's life ran out" → PLAYER WIN UI</item>
|
||||
/// <item><c>LifeWin</c> = "opponent's life win condition met" → PLAYER LOSE UI</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Prior to 2026-06-02 the docstring here claimed the values were 0/1/2 mapping
|
||||
/// LOSE/WIN/CONSISTENCY — that was the HTTP <c>/finish</c> response shape, not the
|
||||
/// WS frame. The previous values are kept for backwards compat with the
|
||||
/// Retire/Kill scripted flow (which has shipped emitting <c>Win = 1</c>, parsed
|
||||
/// client-side as <c>RESULT_CODE.NoContest</c> and rendered as "battle ended in
|
||||
/// no contest" — a working-but-not-quite-right scripted-bot terminator).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Always serialize as the int value, not the name; see the
|
||||
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
|
||||
/// </remarks>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum BattleResult
|
||||
{
|
||||
/// <summary>Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow.
|
||||
/// Wire-equivalent of <c>RESULT_CODE.NotFinish = 0</c>. Don't use for new code.</summary>
|
||||
Lose = 0,
|
||||
/// <summary>Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow.
|
||||
/// Wire-equivalent of <c>RESULT_CODE.NoContest = 1</c>. Don't use for new code.</summary>
|
||||
Win = 1,
|
||||
/// <summary>Pre-2026-06-02 value, kept for the existing Retire/Kill Scripted flow.
|
||||
/// Wire-equivalent of <c>RESULT_CODE.Invalid = 2</c>. Don't use for new code.</summary>
|
||||
Consistency = 2,
|
||||
|
||||
/// <summary>Opponent's life ran out (PLAYER WIN). Pushed by Scripted mode on
|
||||
/// the player's <c>TurnEndFinal</c> emit. Matches the prod TK2 capture at
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson:274</c>.</summary>
|
||||
LifeLose = 102,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
LifeWin = 101,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<SVSim.BattleNode.Protocol.Bodies.BattleFinishBody>());
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user