fix(battle-node): involuntary-drop survivor gets DisconnectWin, not Win=NoContest

Code-review follow-up to the dispatch unification (0a8a84b).

1. The RunAsync drop cascade synthesized BattleFinish(Win=1), which the client
   renders as RESULT_CODE.NoContest ("battle ended in no contest") instead of a
   win. Add DisconnectWin=201 (already in the client enum, routes to WIN UI) and
   ship it for involuntary opponent drops. Update PvpMidGameDisconnect_FullCascade.

2. Remove BuildBattleFinishNoContest() — dead since the Retire/Kill arm moved to
   RetireWin/RetireLose.

3. Correct the BattleResult docstring: Lose/Win/Consistency are no longer emitted
   by any dispatch arm; they survive only as serialization-test constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 19:26:16 -04:00
parent 0a8a84b2cc
commit a198174ede
3 changed files with 22 additions and 21 deletions

View File

@@ -30,12 +30,15 @@ namespace SVSim.BattleNode.Protocol;
/// reproduction that exposed the inversion.
/// </para>
/// <para>
/// The pre-2026-06-02 <c>Lose = 0</c> / <c>Win = 1</c> / <c>Consistency = 2</c>
/// values are kept for the existing Retire/Kill Scripted flow (which ships
/// <c>Win = 1</c>, parsed client-side as <c>RESULT_CODE.NoContest</c> 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.
/// The legacy <c>Lose = 0</c> / <c>Win = 1</c> / <c>Consistency = 2</c> values
/// (client <c>RESULT_CODE.NotFinish</c> / <c>NoContest</c> / <c>Invalid</c>) are
/// no longer emitted by any dispatch arm: the Retire/Kill flow now ships the
/// proper <c>RetireWin</c> / <c>RetireLose</c> codes, and the involuntary-drop
/// cascade ships <c>DisconnectWin</c>. They survive only as wire-constant
/// references in serialization tests and can be deleted once those are dropped.
/// (Historically <c>Win = 1</c> was shipped on Retire and rendered client-side
/// as "battle ended in no contest" — the wrong text — which is why it was
/// replaced.)
/// </para>
/// <para>
/// Always serialize as the int value, not the name; see the
@@ -71,4 +74,11 @@ public enum BattleResult
/// <summary>Player lost by retiring. Pushed to the retirer on <c>Retire</c>/<c>Kill</c>.</summary>
RetireLose = 106,
/// <summary>Survivor wins because the opponent's socket dropped without a graceful
/// <c>Retire</c>. Pushed to the survivor by the <see cref="Sessions.BattleSession"/>
/// RunAsync drop cascade. Client <c>RESULT_CODE.DisconnectWin</c> renders the
/// "opponent disconnected" result text → player WIN UI. Same player-perspective
/// convention as the Life/Retire codes.</summary>
DisconnectWin = 201,
}

View File

@@ -58,11 +58,13 @@ public sealed class BattleSession
if (Phase != BattleSessionPhase.Terminal)
{
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
// WIN UI; the legacy Win=1 used here previously rendered "no contest".
try
{
await survivor.PushAsync(
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -334,17 +336,6 @@ public sealed class BattleSession
PlaySeq: null,
Body: new ResultCodeOnlyBody());
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: BattleResult.Win));
private MsgEnvelope BuildTurnEndBroadcast() => new(
NetworkBattleUri.TurnEnd,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,

View File

@@ -320,11 +320,11 @@ public class BattleNodeFlowTests
// Abruptly close A's WS (no Retire).
await clientA.DisposeAsync();
// B should receive BattleFinish(Win) within a few seconds.
// B should receive BattleFinish(DisconnectWin) within a few seconds.
var bFinish = await clientB.ReceiveSynchronizeAsync(ct);
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var bBody = (RawBody)bFinish.Body;
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.DisconnectWin));
// PendingBattle should be evicted by the second arriver's RemovePending.
var store = factory.Services.GetRequiredService<SVSim.BattleNode.Sessions.IBattleSessionStore>();