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