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:
gamer147
2026-06-02 17:48:20 -04:00
parent c7e61c6f8d
commit ee23985055
3 changed files with 107 additions and 13 deletions

View File

@@ -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,
}

View File

@@ -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:

View File

@@ -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()
{