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_CODEJudgeResultReceive switch → _finishEffectType → +/// FinishBattleEffectInitiateGameEndSequence(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."); }