diff --git a/SVSim.BattleNode/Protocol/BattleResult.cs b/SVSim.BattleNode/Protocol/BattleResult.cs
index e84ec93..01289fc 100644
--- a/SVSim.BattleNode/Protocol/BattleResult.cs
+++ b/SVSim.BattleNode/Protocol/BattleResult.cs
@@ -4,28 +4,38 @@ namespace SVSim.BattleNode.Protocol;
/// 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:
+/// NetworkBattleReceiver.cs:963-986. Names are from the player's perspective:
+/// LifeWin = "I won by life", LifeLose = "I lost by life". Verified
+/// end-to-end via the path
+/// RESULT_CODE → JudgeResultReceive switch → _finishEffectType →
+/// FinishBattleEffect → InitiateGameEndSequence(hasWon):
///
///
-/// - LifeLose = "opponent's life ran out" → PLAYER WIN UI
-/// - LifeWin = "opponent's life win condition met" → PLAYER LOSE UI
+/// - LifeWin = 101 → _finishEffectType = LOSE →
+/// InitiateGameEndSequence(hasWon: true) → PLAYER WIN UI
+/// - LifeLose = 102 → _finishEffectType = WIN →
+/// InitiateGameEndSequence(hasWon: false) → 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).
+/// The SettingResultUI_SpecialResultTypeText switch passes the OPPONENT's
+/// outcome to set the secondary "by retire/by disconnect/etc." text — that's why
+/// the inner switch direction looks inverted (LifeWin → LOSE param, LifeLose → WIN
+/// param). The actual WIN/LOSE rendering happens in FinishBattleEffect via
+/// the !isPlayer flip at line 1315.
+///
+///
+/// Prior docstrings on this enum had the direction backwards (claimed LifeLose
+/// → WIN UI from a misread of the inner switch); see
+/// docs/audits/battle-node-sio-events-2026-06-02.md Addendum for the live
+/// reproduction that exposed the inversion.
+///
+///
+/// The pre-2026-06-02 Lose = 0 / Win = 1 / Consistency = 2
+/// values are kept for the existing Retire/Kill Scripted flow (which ships
+/// Win = 1, parsed client-side as RESULT_CODE.NoContest and
+/// rendered as "battle ended in no contest" — works in that the battle
+/// terminates, but shows the wrong text). To be removed once that flow is
+/// rewritten to use the proper retire codes.
///
///
/// Always serialize as the int value, not the name; see the
@@ -44,12 +54,15 @@ public enum BattleResult
/// 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.
+ /// Player won by reducing opponent's life to 0. Pushed by Scripted mode
+ /// on the player's TurnEndFinal emit. Routes through the client switch to
+ /// InitiateGameEndSequence(hasWon: true).
LifeWin = 101,
+
+ /// Player lost by their own life dropping to 0. Not currently emitted
+ /// by Scripted mode — the bot can't kill the player — but listed for completeness
+ /// and for the prod TK2 capture at
+ /// data_dumps/captures/battle-traffic_tk2_regular.ndjson:274 (a loss the
+ /// player suffered to a real opponent).
+ LifeLose = 102,
}
diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index d8cecc5..7d08ae4 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -260,11 +260,12 @@ public sealed class BattleSession
}
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
+ // Push BattleFinish with LifeWin=101 — names are from the player's
+ // perspective: "I won by life". Verified end-to-end via
+ // FinishBattleEffect:1289-1315 → InitiateGameEndSequence(hasWon: true) →
+ // WIN UI. Don't forward to bot (no next turn). Phase → Terminal so the
// RunAsync cascade doesn't synthesize a follow-up BattleFinish.
- result.Add((from, BuildBattleFinish(BattleResult.LifeLose), true));
+ result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
Phase = BattleSessionPhase.Terminal;
}
// Bot type: no-op (rank-battle finish is HTTP-driven via /ai_*/finish).
diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index a56597c..8de414f 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -83,12 +83,18 @@ public class BattleSessionDispatchTests
}
[Test]
- public void Scripted_TurnEndFinal_pushes_BattleFinish_LifeLose_to_player_and_terminates()
+ public void Scripted_TurnEndFinal_pushes_BattleFinish_LifeWin_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.
+ //
+ // Wire-result direction: RESULT_CODE names are FROM THE PLAYER'S PERSPECTIVE.
+ // LifeWin=101 = "I won by life". The client's FinishBattleEffect:1289-1315 then
+ // routes this through InitiateGameEndSequence(hasWon: true) → WIN UI. (An earlier
+ // version of this test asserted LifeLose=102 — that pushed LOSE UI in live test,
+ // matching the inversion documented in
+ // docs/audits/battle-node-sio-events-2026-06-02.md Addendum.)
var (s, a, b) = NewSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
@@ -105,8 +111,8 @@ public class BattleSessionDispatchTests
"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(body.Result, Is.EqualTo(BattleResult.LifeWin),
+ "Wire result must be RESULT_CODE.LifeWin (101) — names are player-perspective; client routes this to WIN UI.");
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
}